From 084d3c210b4100c28c8dd120077fedb74e05b72f Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Sun, 9 Aug 2026 20:32:58 -0400 Subject: [PATCH] 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 --body "..." [--health onTrack|atRisk|offTrack]`, resolving PROJECT the same way issue list's --project does. Closes #42. Co-Authored-By: Claude Sonnet 5 --- Readme.adoc | 10 +++ app/lib/linear_cli/cli.ex | 26 +++++++ app/lib/linear_cli/cli/commands.ex | 20 ++++++ app/lib/linear_cli/cli/display.ex | 7 +- app/lib/linear_cli/linear.ex | 4 ++ app/lib/linear_cli/linear/project_update.ex | 79 +++++++++++++++++++++ app/test/linear_cli/cli_test.exs | 73 ++++++++++++++++++- 7 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 app/lib/linear_cli/linear/project_update.ex diff --git a/Readme.adoc b/Readme.adoc index 02a1b45..0ae833e 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -226,6 +226,16 @@ $ lcomment CRY-1234 CRY-3 <5> $ lc issue update --close --reason "These were closable" CRY-1234 CRY-2 ---- +==== Post a project status update + +Not in Ruby's `linear-cli` - a status post on a project (Linear's own "Project +Update" feature), not an edit to the project itself. + +[source,sh] +---- +$ lc project update Manhattan --body "Shipping ahead of schedule" --health onTrack +---- + === Wrapper scripts The `bin/` wrapper scripts (ported verbatim from `linear-cli`'s own `exe/scripts/`) diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index ad9253d..bc1c414 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -162,6 +162,10 @@ defmodule LinearCli.CLI do defp dispatch([:version], result, halt), do: run(&Commands.version/1, result, halt) defp dispatch([:team, :list], result, halt), do: run(&Commands.team_list/1, result, halt) defp dispatch([:project, :list], result, halt), do: run(&Commands.project_list/1, result, halt) + + defp dispatch([:project, :update], result, halt), + do: run(&Commands.project_update/1, result, halt) + defp dispatch([:issue, :list], result, halt), do: run(&Commands.issue_list/1, result, halt) defp dispatch([:issue, :create], result, halt), do: run(&Commands.issue_create/1, result, halt) @@ -345,6 +349,28 @@ defmodule LinearCli.CLI do options: [ team: [short: "-t", long: "--team", help: "Show projects for only this team"] ] + ], + update: [ + name: "update", + about: "Post a status update to a project", + args: [ + project: [ + value_name: "PROJECT", + help: "Project name, URL, ID, or search term", + required: true + ] + ], + options: [ + body: [short: "-b", long: "--body", help: "The update's content (markdown)"], + health: [ + long: "--health", + help: "Project health: onTrack, atRisk, or offTrack", + parser: fn + v when v in ["onTrack", "atRisk", "offTrack"] -> {:ok, v} + v -> {:error, "must be one of: onTrack, atRisk, offTrack (got #{inspect(v)})"} + end + ] + ] ] ] ], diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 270716c..c31f7f8 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -59,6 +59,26 @@ defmodule LinearCli.CLI.Commands do defp projects_for(%{mine: true}, _options), do: Linear.my_projects() defp projects_for(_flags, _options), do: Linear.projects() + @doc """ + New in this port - Ruby has no equivalent. Posts a status update + (Linear's own "Project Update" feature - a journal-style status post, + not an edit to the project's own fields) via the projectUpdateCreate + mutation. `PROJECT` is resolved the same way issue list's `--project` + is - against every project in the workspace, prompting if ambiguous. + """ + def project_update(%{args: %{project: search}, options: options}) do + with {:ok, projects} <- Linear.projects(), + project when not is_nil(project) <- Projects.project_for(projects, search), + {:ok, update} <- + Linear.post_project_update(project.id, options.body, %{health: options.health}) do + Display.show(update, %{output: options.output}) + :ok + else + nil -> {:error, {:smells_bad, "No project found matching #{search}"}} + {:error, reason} -> {:error, reason} + end + end + @doc """ Ported from commands/issue/list.rb + operations/issue/list.rb. diff --git a/app/lib/linear_cli/cli/display.ex b/app/lib/linear_cli/cli/display.ex index 56af448..b936b08 100644 --- a/app/lib/linear_cli/cli/display.ex +++ b/app/lib/linear_cli/cli/display.ex @@ -7,7 +7,7 @@ defmodule LinearCli.CLI.Display do own `#to_s`/`#full`/`#display` methods. """ - alias LinearCli.Linear.{Issue, Project, Team, User} + alias LinearCli.Linear.{Issue, Project, ProjectUpdate, Team, User} @ash_internal_fields ~w(__meta__ __metadata__ __order__ __lateral_join_source__ aggregates calculations)a @@ -31,6 +31,11 @@ defmodule LinearCli.CLI.Display do IO.puts("#{String.pad_trailing(project.name || "", 12)} #{project.url}") end + defp puts_text(%ProjectUpdate{} = update, _opts) do + health = if update.health, do: " (#{update.health})", else: "" + IO.puts("Posted#{health}: #{update.url}") + end + defp puts_text(%User{} = user, opts) do IO.puts(user_line(user, opts)) end diff --git a/app/lib/linear_cli/linear.ex b/app/lib/linear_cli/linear.ex index cffc69b..87883c8 100644 --- a/app/lib/linear_cli/linear.ex +++ b/app/lib/linear_cli/linear.ex @@ -49,5 +49,9 @@ defmodule LinearCli.Linear do resource LinearCli.Linear.Comment do define :add_comment, action: :create, args: [:issue_identifier, :body] end + + resource LinearCli.Linear.ProjectUpdate do + define :post_project_update, action: :create, args: [:project_id, :body] + end end end diff --git a/app/lib/linear_cli/linear/project_update.ex b/app/lib/linear_cli/linear/project_update.ex new file mode 100644 index 0000000..10d431a --- /dev/null +++ b/app/lib/linear_cli/linear/project_update.ex @@ -0,0 +1,79 @@ +defmodule LinearCli.Linear.ProjectUpdate do + @moduledoc """ + A Linear project update - a status post on a project (e.g. "This week's + progress..."), distinct from editing the project's own fields. New in + this port - Ruby has no equivalent. Created via the projectUpdateCreate + mutation (schema/LinearAPI.graphql). + """ + + use Ash.Resource, domain: LinearCli.Linear + + actions do + create :create do + argument :project_id, :string, allow_nil?: false + argument :body, :string, allow_nil?: false + argument :health, :string, allow_nil?: true + manual LinearCli.Linear.ProjectUpdate.Create + end + end + + attributes do + attribute :id, :string, primary_key?: true, allow_nil?: false, public?: true + attribute :body, :string, public?: true + attribute :health, :string, public?: true + attribute :url, :string, public?: true + end + + @doc "GraphQL field selection for a project update's own fields." + def base_fields do + "id body health url createdAt" + end + + @doc false + def from_map(map) do + struct!(__MODULE__, + id: map["id"], + body: map["body"], + health: map["health"], + url: map["url"] + ) + end +end + +defmodule LinearCli.Linear.ProjectUpdate.Create do + @moduledoc false + use Ash.Resource.ManualCreate + + alias LinearCli.Api + alias LinearCli.Linear.ProjectUpdate + + def create(changeset, _opts, _context) do + args = changeset.arguments + + input = + %{"projectId" => args.project_id, "body" => args.body} + |> maybe_put_health(args.health) + + case Api.call(document(), %{"input" => input}) do + {:ok, %{"projectUpdateCreate" => %{"projectUpdate" => update_map}}} + when is_map(update_map) -> + {:ok, ProjectUpdate.from_map(update_map)} + + {:ok, other} -> + {:error, {:unexpected_response, other}} + + {:error, reason} -> + {:error, reason} + end + end + + defp maybe_put_health(input, nil), do: input + defp maybe_put_health(input, health), do: Map.put(input, "health", health) + + # A function, not a module attribute: ProjectUpdate.base_fields/0 reaches + # into no other file today, but kept consistent with every other + # document/0 in this codebase for the same reason they all are. + defp document do + "mutation($input: ProjectUpdateCreateInput!) { projectUpdateCreate(input: $input) { projectUpdate { #{ProjectUpdate.base_fields()} } } }" + end +end diff --git a/app/test/linear_cli/cli_test.exs b/app/test/linear_cli/cli_test.exs index 848c369..16fb7b0 100644 --- a/app/test/linear_cli/cli_test.exs +++ b/app/test/linear_cli/cli_test.exs @@ -106,6 +106,77 @@ defmodule LinearCli.CLITest do assert capture_io(fn -> LinearCli.CLI.main(["project", "list"]) end) =~ "Manhattan" end + test "project update resolves the project by name and posts a status update" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + + cond do + String.contains?(query, "projects(first: $first") -> + Req.Test.json(conn, %{ + "data" => %{ + "projects" => %{ + "edges" => [ + %{ + "node" => %{ + "id" => "p1", + "name" => "Manhattan", + "slugId" => "abc", + "url" => "https://linear.app/x/project/manhattan-abc" + }, + "cursor" => "p1" + } + ], + "pageInfo" => %{"hasNextPage" => false} + } + } + }) + + String.contains?(query, "projectUpdateCreate") -> + send(test_pid, {:input, decoded["variables"]["input"]}) + + Req.Test.json(conn, %{ + "data" => %{ + "projectUpdateCreate" => %{ + "projectUpdate" => %{ + "id" => "pu1", + "body" => "Doing great", + "health" => "onTrack", + "url" => "https://linear.app/x/update/pu1" + } + } + } + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "project", + "update", + "Manhattan", + "--body", + "Doing great", + "--health", + "onTrack" + ]) + end) + + assert output =~ "onTrack" + assert output =~ "https://linear.app/x/update/pu1" + + assert_received {:input, + %{"projectId" => "p1", "body" => "Doing great", "health" => "onTrack"}} + end + test "issue list prints a one-line summary per issue" do assert capture_io(fn -> LinearCli.CLI.main(["issue", "list"]) end) =~ "CRY-1" end @@ -281,7 +352,7 @@ defmodule LinearCli.CLITest do assert_received {:halted, 1} assert output =~ "Manage projects" - assert output =~ "list List projects" + assert output =~ "List projects" end test "an unexpected raise (not a returned error) still degrades to exit 88, not a raw crash" do