Skip to content

Commit 084d3c2

Browse files
bougymanclaude
andcommitted
feat(cli): add project update - post a status update to a project
New in this port - Ruby has no equivalent, not even at the API-wrapper level. Linear has its own "Project Update" feature (a journal-style status post on a project, distinct from editing the project's own fields) via the projectUpdateCreate mutation. Adds a new ProjectUpdate resource (mirrors Comment's shape) and `lc project update <PROJECT> --body "..." [--health onTrack|atRisk|offTrack]`, resolving PROJECT the same way issue list's --project does. Closes #42. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 147f219 commit 084d3c2

7 files changed

Lines changed: 217 additions & 2 deletions

File tree

Readme.adoc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,16 @@ $ lcomment CRY-1234 CRY-3 <5>
226226
$ lc issue update --close --reason "These were closable" CRY-1234 CRY-2
227227
----
228228

229+
==== Post a project status update
230+
231+
Not in Ruby's `linear-cli` - a status post on a project (Linear's own "Project
232+
Update" feature), not an edit to the project itself.
233+
234+
[source,sh]
235+
----
236+
$ lc project update Manhattan --body "Shipping ahead of schedule" --health onTrack
237+
----
238+
229239
=== Wrapper scripts
230240

231241
The `bin/` wrapper scripts (ported verbatim from `linear-cli`'s own `exe/scripts/`)

app/lib/linear_cli/cli.ex

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ defmodule LinearCli.CLI do
162162
defp dispatch([:version], result, halt), do: run(&Commands.version/1, result, halt)
163163
defp dispatch([:team, :list], result, halt), do: run(&Commands.team_list/1, result, halt)
164164
defp dispatch([:project, :list], result, halt), do: run(&Commands.project_list/1, result, halt)
165+
166+
defp dispatch([:project, :update], result, halt),
167+
do: run(&Commands.project_update/1, result, halt)
168+
165169
defp dispatch([:issue, :list], result, halt), do: run(&Commands.issue_list/1, result, halt)
166170
defp dispatch([:issue, :create], result, halt), do: run(&Commands.issue_create/1, result, halt)
167171

@@ -345,6 +349,28 @@ defmodule LinearCli.CLI do
345349
options: [
346350
team: [short: "-t", long: "--team", help: "Show projects for only this team"]
347351
]
352+
],
353+
update: [
354+
name: "update",
355+
about: "Post a status update to a project",
356+
args: [
357+
project: [
358+
value_name: "PROJECT",
359+
help: "Project name, URL, ID, or search term",
360+
required: true
361+
]
362+
],
363+
options: [
364+
body: [short: "-b", long: "--body", help: "The update's content (markdown)"],
365+
health: [
366+
long: "--health",
367+
help: "Project health: onTrack, atRisk, or offTrack",
368+
parser: fn
369+
v when v in ["onTrack", "atRisk", "offTrack"] -> {:ok, v}
370+
v -> {:error, "must be one of: onTrack, atRisk, offTrack (got #{inspect(v)})"}
371+
end
372+
]
373+
]
348374
]
349375
]
350376
],

app/lib/linear_cli/cli/commands.ex

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,26 @@ defmodule LinearCli.CLI.Commands do
5959
defp projects_for(%{mine: true}, _options), do: Linear.my_projects()
6060
defp projects_for(_flags, _options), do: Linear.projects()
6161

62+
@doc """
63+
New in this port - Ruby has no equivalent. Posts a status update
64+
(Linear's own "Project Update" feature - a journal-style status post,
65+
not an edit to the project's own fields) via the projectUpdateCreate
66+
mutation. `PROJECT` is resolved the same way issue list's `--project`
67+
is - against every project in the workspace, prompting if ambiguous.
68+
"""
69+
def project_update(%{args: %{project: search}, options: options}) do
70+
with {:ok, projects} <- Linear.projects(),
71+
project when not is_nil(project) <- Projects.project_for(projects, search),
72+
{:ok, update} <-
73+
Linear.post_project_update(project.id, options.body, %{health: options.health}) do
74+
Display.show(update, %{output: options.output})
75+
:ok
76+
else
77+
nil -> {:error, {:smells_bad, "No project found matching #{search}"}}
78+
{:error, reason} -> {:error, reason}
79+
end
80+
end
81+
6282
@doc """
6383
Ported from commands/issue/list.rb + operations/issue/list.rb.
6484

app/lib/linear_cli/cli/display.ex

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ defmodule LinearCli.CLI.Display do
77
own `#to_s`/`#full`/`#display` methods.
88
"""
99

10-
alias LinearCli.Linear.{Issue, Project, Team, User}
10+
alias LinearCli.Linear.{Issue, Project, ProjectUpdate, Team, User}
1111

1212
@ash_internal_fields ~w(__meta__ __metadata__ __order__ __lateral_join_source__ aggregates calculations)a
1313

@@ -31,6 +31,11 @@ defmodule LinearCli.CLI.Display do
3131
IO.puts("#{String.pad_trailing(project.name || "", 12)} #{project.url}")
3232
end
3333

34+
defp puts_text(%ProjectUpdate{} = update, _opts) do
35+
health = if update.health, do: " (#{update.health})", else: ""
36+
IO.puts("Posted#{health}: #{update.url}")
37+
end
38+
3439
defp puts_text(%User{} = user, opts) do
3540
IO.puts(user_line(user, opts))
3641
end

app/lib/linear_cli/linear.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,9 @@ defmodule LinearCli.Linear do
4949
resource LinearCli.Linear.Comment do
5050
define :add_comment, action: :create, args: [:issue_identifier, :body]
5151
end
52+
53+
resource LinearCli.Linear.ProjectUpdate do
54+
define :post_project_update, action: :create, args: [:project_id, :body]
55+
end
5256
end
5357
end
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
defmodule LinearCli.Linear.ProjectUpdate do
2+
@moduledoc """
3+
A Linear project update - a status post on a project (e.g. "This week's
4+
progress..."), distinct from editing the project's own fields. New in
5+
this port - Ruby has no equivalent. Created via the projectUpdateCreate
6+
mutation (schema/LinearAPI.graphql).
7+
"""
8+
9+
use Ash.Resource, domain: LinearCli.Linear
10+
11+
actions do
12+
create :create do
13+
argument :project_id, :string, allow_nil?: false
14+
argument :body, :string, allow_nil?: false
15+
argument :health, :string, allow_nil?: true
16+
manual LinearCli.Linear.ProjectUpdate.Create
17+
end
18+
end
19+
20+
attributes do
21+
attribute :id, :string, primary_key?: true, allow_nil?: false, public?: true
22+
attribute :body, :string, public?: true
23+
attribute :health, :string, public?: true
24+
attribute :url, :string, public?: true
25+
end
26+
27+
@doc "GraphQL field selection for a project update's own fields."
28+
def base_fields do
29+
"id body health url createdAt"
30+
end
31+
32+
@doc false
33+
def from_map(map) do
34+
struct!(__MODULE__,
35+
id: map["id"],
36+
body: map["body"],
37+
health: map["health"],
38+
url: map["url"]
39+
)
40+
end
41+
end
42+
43+
defmodule LinearCli.Linear.ProjectUpdate.Create do
44+
@moduledoc false
45+
use Ash.Resource.ManualCreate
46+
47+
alias LinearCli.Api
48+
alias LinearCli.Linear.ProjectUpdate
49+
50+
def create(changeset, _opts, _context) do
51+
args = changeset.arguments
52+
53+
input =
54+
%{"projectId" => args.project_id, "body" => args.body}
55+
|> maybe_put_health(args.health)
56+
57+
case Api.call(document(), %{"input" => input}) do
58+
{:ok, %{"projectUpdateCreate" => %{"projectUpdate" => update_map}}}
59+
when is_map(update_map) ->
60+
{:ok, ProjectUpdate.from_map(update_map)}
61+
62+
{:ok, other} ->
63+
{:error, {:unexpected_response, other}}
64+
65+
{:error, reason} ->
66+
{:error, reason}
67+
end
68+
end
69+
70+
defp maybe_put_health(input, nil), do: input
71+
defp maybe_put_health(input, health), do: Map.put(input, "health", health)
72+
73+
# A function, not a module attribute: ProjectUpdate.base_fields/0 reaches
74+
# into no other file today, but kept consistent with every other
75+
# document/0 in this codebase for the same reason they all are.
76+
defp document do
77+
"mutation($input: ProjectUpdateCreateInput!) { projectUpdateCreate(input: $input) { projectUpdate { #{ProjectUpdate.base_fields()} } } }"
78+
end
79+
end

app/test/linear_cli/cli_test.exs

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,77 @@ defmodule LinearCli.CLITest do
106106
assert capture_io(fn -> LinearCli.CLI.main(["project", "list"]) end) =~ "Manhattan"
107107
end
108108

109+
test "project update resolves the project by name and posts a status update" do
110+
test_pid = self()
111+
112+
Req.Test.stub(LinearCli.Api, fn conn ->
113+
{:ok, body, conn} = Plug.Conn.read_body(conn)
114+
decoded = Jason.decode!(body)
115+
query = decoded["query"]
116+
117+
cond do
118+
String.contains?(query, "projects(first: $first") ->
119+
Req.Test.json(conn, %{
120+
"data" => %{
121+
"projects" => %{
122+
"edges" => [
123+
%{
124+
"node" => %{
125+
"id" => "p1",
126+
"name" => "Manhattan",
127+
"slugId" => "abc",
128+
"url" => "https://linear.app/x/project/manhattan-abc"
129+
},
130+
"cursor" => "p1"
131+
}
132+
],
133+
"pageInfo" => %{"hasNextPage" => false}
134+
}
135+
}
136+
})
137+
138+
String.contains?(query, "projectUpdateCreate") ->
139+
send(test_pid, {:input, decoded["variables"]["input"]})
140+
141+
Req.Test.json(conn, %{
142+
"data" => %{
143+
"projectUpdateCreate" => %{
144+
"projectUpdate" => %{
145+
"id" => "pu1",
146+
"body" => "Doing great",
147+
"health" => "onTrack",
148+
"url" => "https://linear.app/x/update/pu1"
149+
}
150+
}
151+
}
152+
})
153+
154+
true ->
155+
raise "no stub matched query: #{query}"
156+
end
157+
end)
158+
159+
output =
160+
capture_io(fn ->
161+
assert :ok =
162+
LinearCli.CLI.main([
163+
"project",
164+
"update",
165+
"Manhattan",
166+
"--body",
167+
"Doing great",
168+
"--health",
169+
"onTrack"
170+
])
171+
end)
172+
173+
assert output =~ "onTrack"
174+
assert output =~ "https://linear.app/x/update/pu1"
175+
176+
assert_received {:input,
177+
%{"projectId" => "p1", "body" => "Doing great", "health" => "onTrack"}}
178+
end
179+
109180
test "issue list prints a one-line summary per issue" do
110181
assert capture_io(fn -> LinearCli.CLI.main(["issue", "list"]) end) =~ "CRY-1"
111182
end
@@ -281,7 +352,7 @@ defmodule LinearCli.CLITest do
281352

282353
assert_received {:halted, 1}
283354
assert output =~ "Manage projects"
284-
assert output =~ "list List projects"
355+
assert output =~ "List projects"
285356
end
286357

287358
test "an unexpected raise (not a returned error) still degrades to exit 88, not a raw crash" do

0 commit comments

Comments
 (0)