From e770f22e66a36427b633a11175c04db50f086b2b Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Tue, 18 Aug 2026 19:40:06 -0400 Subject: [PATCH 1/3] feat(issue): add --status/-s to assign and take commands Allow `lc issue assign` and `lc issue take` to set a workflow state alongside the assignment in a single Linear issueUpdate mutation. - Extend the `:assign` Ash action with an optional `state_id` argument; `Issue.Update.Assign` conditionally adds `stateId` to the mutation input - Add `--status`/`-s` to both `assign` and `take` CLI subcommand specs - `issue assign` resolves the status name against the issue team's workflow states (case-insensitive, prefix match) before calling the API; invalid or ambiguous names exit 22 before any mutation fires - `issue take` threads the status name through per-issue so each issue resolves against its own team's states (different teams can use different state IDs for the same name) - The already-assigned shortcut in `gimme_da_issue!` is bypassed when a status is requested, so `take --status` always updates the state even when the caller is already the assignee - Backward compatible: omitting `--status` sends only `assigneeId` with no `stateId` key in the mutation, matching existing behaviour exactly - Updates Readme.adoc with examples and ash-domain-erd.adoc action inventory Co-Authored-By: Claude Sonnet 4.6 --- Readme.adoc | 18 + app/lib/linear_cli/cli.ex | 14 +- app/lib/linear_cli/cli/commands.ex | 29 +- app/lib/linear_cli/cli/issue_helpers.ex | 58 +- app/lib/linear_cli/linear/issue.ex | 11 +- .../linear_cli/cli/issue_commands_test.exs | 605 ++++++++++++++++++ .../linear_cli/cli/issue_helpers_test.exs | 229 +++++++ documents/ash-domain-erd.adoc | 2 +- 8 files changed, 953 insertions(+), 13 deletions(-) diff --git a/Readme.adoc b/Readme.adoc index 6c4001e..3cc29da 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -208,7 +208,25 @@ $ lcls -f CRY-1 ---- $ lc issue take CRY-1234 $ lc issue take CRY-456 CRY-789 +$ lc issue take CRY-1234 --status "In Progress" <1> +$ lc issue take CRY-1234 -s Todo <2> ---- +<1> Self-assign and move to the named workflow state in one update +<2> Short form of `--status` + +==== Assign an issue to a team member + +[source,sh] +---- +$ lc issue assign CRY-1234 --assignee alice <1> +$ lc issue assign CRY-1234 -a alice --status "In Progress" <2> +$ lc issue assign CRY-1234 -a alice -s Todo <3> +$ lc issue assign CRY-1234 <4> +---- +<1> Assign to a team member by name (case-insensitive, prefix match) +<2> Assign and move to the named workflow state in one update +<3> Short form of `--status` +<4> Prompts for the assignee interactively ==== Create an issue diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 9281bf6..961cd98 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -577,6 +577,11 @@ defmodule LinearCli.CLI do short: "-a", long: "--assignee", help: "Team member name to assign to (prompts if omitted)" + ], + status: [ + short: "-s", + long: "--status", + help: "Workflow state name to set after assigning (e.g. \"In Progress\")" ] ] ], @@ -641,7 +646,14 @@ defmodule LinearCli.CLI do take: [ name: "take", about: "Assign one or more issues to yourself", - allow_unknown_args: true + allow_unknown_args: true, + options: [ + status: [ + short: "-s", + long: "--status", + help: "Workflow state name to set after self-assigning (e.g. \"In Progress\")" + ] + ] ], update: [ name: "update", diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 74fb590..4d346e1 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -408,12 +408,17 @@ defmodule LinearCli.CLI.Commands do def issue_take(result, opts \\ []) def issue_take(%{unknown: issue_ids, options: options}, opts) do + opts = maybe_put_status(opts, Map.get(options, :status)) + with {:ok, updates} <- take_issues(issue_ids, opts) do Display.show(updates, %{output: options.output}) :ok end end + defp maybe_put_status(opts, nil), do: opts + defp maybe_put_status(opts, status), do: Keyword.put(opts, :status, status) + defp take_issues(issue_ids, opts) do issue_ids |> Enum.reduce_while({:ok, []}, fn issue_id, {:ok, acc} -> @@ -545,6 +550,15 @@ defmodule LinearCli.CLI.Commands do end end + defp resolve_optional_status(_issue, nil), do: {:ok, nil} + + defp resolve_optional_status(issue, name) do + with {:ok, states} <- Linear.workflow_states_by_team(issue.team.id), + {:ok, state} <- resolve_target_state(states, name) do + {:ok, state.id} + end + end + @doc """ Assigns an issue to a team member. @@ -560,11 +574,20 @@ defmodule LinearCli.CLI.Commands do {:ok, members} <- Linear.team_members(issue.team.id), :ok <- guard_has_members(members, issue), {:ok, target_member} <- resolve_target_member(members, options.assignee), - {:ok, updated} <- Linear.assign_issue(issue, target_member.id) do + {:ok, state_id} <- resolve_optional_status(issue, Map.get(options, :status)), + {:ok, updated} <- Linear.assign_issue(issue, target_member.id, %{state_id: state_id}) do Display.show(updated, %{output: options.output}) - if options.output != "json", - do: Prompt.ok("#{updated.identifier} assigned to #{target_member.name}") + if options.output != "json" do + msg = "#{updated.identifier} assigned to #{target_member.name}" + + msg = + if updated.state, + do: "#{msg} and set to #{updated.state.name}", + else: msg + + Prompt.ok(msg) + end :ok end diff --git a/app/lib/linear_cli/cli/issue_helpers.ex b/app/lib/linear_cli/cli/issue_helpers.ex index da6d204..b560de8 100644 --- a/app/lib/linear_cli/cli/issue_helpers.ex +++ b/app/lib/linear_cli/cli/issue_helpers.ex @@ -458,21 +458,69 @@ defmodule LinearCli.CLI.IssueHelpers do @spec gimme_da_issue!(String.t(), keyword()) :: {:ok, %Linear.Issue{}} | {:error, term()} def gimme_da_issue!(issue_id, opts \\ []) do issue_id = expand_issue_id(issue_id) + status_opt = parse_status_opt(opts) with {:ok, me} <- resolve_me(opts), - {:ok, [issue]} <- Linear.issues(%{ids: [issue_id]}) do - assign_or_confirm(issue, me, issue_id) + {:ok, [issue]} <- Linear.issues(%{ids: [issue_id]}), + {:ok, state_id} <- resolve_status_for_issue(issue, status_opt) do + assign_or_confirm(issue, me, issue_id, state_id) end end - defp assign_or_confirm(%{assignee: %{id: id}} = issue, %{id: id}, issue_id) do + defp parse_status_opt(opts) do + case Keyword.fetch(opts, :state_id) do + {:ok, id} -> {:resolved, id} + :error -> {:name, Keyword.get(opts, :status)} + end + end + + defp resolve_status_for_issue(_issue, {:resolved, id}), do: {:ok, id} + defp resolve_status_for_issue(_issue, {:name, nil}), do: {:ok, nil} + + defp resolve_status_for_issue(issue, {:name, name}) do + with {:ok, states} <- Linear.workflow_states_by_team(issue.team.id) do + case resolve_workflow_state(states, name) do + {:ok, state} -> {:ok, state.id} + error -> error + end + end + end + + defp resolve_workflow_state(states, name) do + normalized = String.downcase(name) + + states + |> Enum.filter(&(String.downcase(&1.name) == normalized)) + |> use_prefix_state_matches_if_empty(states, normalized) + |> resolve_workflow_state_matches(states, name) + end + + defp use_prefix_state_matches_if_empty([], states, name) do + Enum.filter(states, &String.starts_with?(String.downcase(&1.name), name)) + end + + defp use_prefix_state_matches_if_empty(matches, _states, _name), do: matches + + defp resolve_workflow_state_matches([state], _states, _name), do: {:ok, state} + + defp resolve_workflow_state_matches([], states, name) do + available = Enum.map_join(states, ", ", & &1.name) + smells_bad("Unknown status #{inspect(name)}. Available: #{available}") + end + + defp resolve_workflow_state_matches(matches, _states, name) do + ambiguous = Enum.map_join(matches, ", ", & &1.name) + smells_bad("Ambiguous status #{inspect(name)}: matches #{ambiguous}") + end + + defp assign_or_confirm(%{assignee: %{id: id}} = issue, %{id: id}, issue_id, nil) do Prompt.say("You are already assigned #{issue_id}") {:ok, issue} end - defp assign_or_confirm(issue, me, issue_id) do + defp assign_or_confirm(issue, me, issue_id, state_id) do Prompt.say("Assigning issue #{issue_id} to ya") - Linear.assign_issue(issue, me.id) + Linear.assign_issue(issue, me.id, %{state_id: state_id}) end defp resolve_me(opts) do diff --git a/app/lib/linear_cli/linear/issue.ex b/app/lib/linear_cli/linear/issue.ex index b39da05..03f7597 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -31,6 +31,7 @@ defmodule LinearCli.Linear.Issue do # Ruby: Issue#assign!(user) update :assign do argument :assignee_id, :string, allow_nil?: false + argument :state_id, :string, allow_nil?: true manual LinearCli.Linear.Issue.Update.Assign end @@ -337,9 +338,13 @@ defmodule LinearCli.Linear.Issue.Update.Assign do alias LinearCli.Linear.Issue def update(changeset, _opts, _context) do - Issue.Update.run(changeset.data.identifier, %{ - "assigneeId" => changeset.arguments.assignee_id - }) + args = changeset.arguments + state_id = Map.get(args, :state_id) + + input = %{"assigneeId" => args.assignee_id} + input = if state_id, do: Map.put(input, "stateId", state_id), else: input + + Issue.Update.run(changeset.data.identifier, input) end end diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index a4a105a..11c937d 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -1324,5 +1324,610 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert stderr =~ "No assignable members" assert stderr =~ "This smells bad! Bailing." end + + test "--status sends assigneeId and stateId in one issueUpdate" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "members(first: 50)") -> + Req.Test.json(conn, members_response([member_map("u2", "Bob")])) + + String.contains?(query, "states {") -> + Req.Test.json( + conn, + workflow_states([ + state_map("s2", "In Progress", 1.0, "started") + ]) + ) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json( + conn, + issue_assigned(member_map("u2", "Bob")) |> put_in( + ["data", "issueUpdate", "issue", "state"], + %{"id" => "s2", "name" => "In Progress", "type" => "started"} + ) + ) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "assign", + "--assignee", + "Bob", + "--status", + "In Progress", + "CRY-1" + ]) + end) + + assert_received {:input, input} + assert input["assigneeId"] == "u2" + assert input["stateId"] == "s2" + assert output =~ "assigned to Bob" + assert output =~ "In Progress" + end + + test "--status short form -s also works on assign" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "members(first: 50)") -> + Req.Test.json(conn, members_response([member_map("u2", "Bob")])) + + String.contains?(query, "states {") -> + Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "assign", "-a", "Bob", "-s", "Todo", "CRY-1"]) + end) + + assert_received {:input, input} + assert input["stateId"] == "s1" + end + + test "--status with case-insensitive name match on assign" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "members(first: 50)") -> + Req.Test.json(conn, members_response([member_map("u2", "Bob")])) + + String.contains?(query, "states {") -> + Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "assign", "-a", "Bob", "--status", "todo", "CRY-1"]) + end) + + assert_received {:input, input} + assert input["stateId"] == "s1" + end + + test "--status unknown name exits 22 before sending any mutation on assign" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "members(first: 50)") -> + Req.Test.json(conn, members_response([member_map("u2", "Bob")])) + + String.contains?(query, "states {") -> + Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) + + String.contains?(query, "issueUpdate") -> + send(test_pid, :mutated) + raise "issueUpdate should not be called when status is invalid" + + true -> + raise "no stub matched query: #{query}" + end + end) + + stderr = + capture_io(:stderr, fn -> + LinearCli.CLI.main( + ["issue", "assign", "-a", "Bob", "--status", "NoSuchState", "CRY-1"], + halt + ) + end) + + assert_received {:halted, 22} + refute_received :mutated + assert stderr =~ "Unknown status" + end + + test "omitting --status sends only assigneeId (backward compat) on assign" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "members(first: 50)") -> + Req.Test.json(conn, members_response([member_map("u2", "Bob")])) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "assign", "-a", "Bob", "CRY-1"]) + end) + + assert_received {:input, input} + assert input == %{"assigneeId" => "u2"} + refute Map.has_key?(input, "stateId") + end + + test "--output json with --status returns structured output on assign" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "members(first: 50)") -> + Req.Test.json(conn, members_response([member_map("u2", "Bob")])) + + String.contains?(query, "states {") -> + Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) + + String.contains?(query, "issueUpdate") -> + Req.Test.json( + conn, + issue_assigned(member_map("u2", "Bob")) |> put_in( + ["data", "issueUpdate", "issue", "state"], + %{"id" => "s1", "name" => "Todo", "type" => "unstarted"} + ) + ) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "assign", + "-a", + "Bob", + "--status", + "Todo", + "--output", + "json", + "CRY-1" + ]) + end) + + assert {:ok, decoded} = Jason.decode(output) + assert decoded["identifier"] == "CRY-1" + assert decoded["state"]["name"] == "Todo" + end + + test "--status with space in name works on assign" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "members(first: 50)") -> + Req.Test.json(conn, members_response([member_map("u2", "Bob")])) + + String.contains?(query, "states {") -> + Req.Test.json( + conn, + workflow_states([state_map("s-ip", "In Progress", 1.0, "started")]) + ) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "assign", + "-a", + "Bob", + "--status", + "In Progress", + "CRY-53" + ]) + end) + + assert_received {:input, input} + assert input["stateId"] == "s-ip" + end + end + + describe "issue take with --status" do + defp take_member_map, do: %{"id" => "u1", "name" => "Ada", "email" => "ada@x.com"} + + defp take_issue_map(overrides \\ %{}) do + Map.merge( + %{ + "id" => "i1", + "identifier" => "CRY-1", + "title" => "Fix the thing", + "branchName" => "cry-1-fix-the-thing", + "description" => nil, + "assignee" => nil, + "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, + "comments" => %{"nodes" => []} + }, + overrides + ) + end + + test "--status sends both assigneeId and stateId in one issueUpdate" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + cond do + query =~ "viewer" -> + Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) + + query =~ "issue(id: $id)" -> + Req.Test.json(conn, %{"data" => %{"issue" => take_issue_map()}}) + + query =~ "states {" -> + Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) + + query =~ "issueUpdate" -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "take", "--status", "Todo", "CRY-1"]) + end) + + assert_received {:input, input} + assert input["assigneeId"] == "u1" + assert input["stateId"] == "s1" + end + + test "-s short form works on take" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + cond do + query =~ "viewer" -> + Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) + + query =~ "issue(id: $id)" -> + Req.Test.json(conn, %{"data" => %{"issue" => take_issue_map()}}) + + query =~ "states {" -> + Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) + + query =~ "issueUpdate" -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "take", "-s", "Todo", "CRY-1"]) + end) + + assert_received {:input, input} + assert input["stateId"] == "s1" + end + + test "already-self-assigned issue still updates status when --status given" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + cond do + query =~ "viewer" -> + Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) + + query =~ "issue(id: $id)" -> + Req.Test.json( + conn, + %{ + "data" => %{ + "issue" => take_issue_map(%{"assignee" => take_member_map()}) + } + } + ) + + query =~ "states {" -> + Req.Test.json(conn, workflow_states([state_map("s2", "In Progress", 1.0, "started")])) + + query =~ "issueUpdate" -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "take", "--status", "In Progress", "CRY-1"]) + end) + + assert_received {:input, input} + assert input["assigneeId"] == "u1" + assert input["stateId"] == "s2" + end + + test "--status unknown name exits 22 before any mutation on take" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + query =~ "viewer" -> + Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) + + query =~ "issue(id: $id)" -> + Req.Test.json(conn, %{"data" => %{"issue" => take_issue_map()}}) + + query =~ "states {" -> + Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) + + query =~ "issueUpdate" -> + send(test_pid, :mutated) + raise "issueUpdate should not be called" + + true -> + raise "no stub matched query: #{query}" + end + end) + + stderr = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "take", "--status", "Bogus", "CRY-1"], halt) + end) + + assert_received {:halted, 22} + refute_received :mutated + assert stderr =~ "Unknown status" + end + + test "omitting --status sends only assigneeId on take (backward compat)" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + cond do + query =~ "viewer" -> + Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) + + query =~ "issue(id: $id)" -> + Req.Test.json(conn, %{"data" => %{"issue" => take_issue_map()}}) + + query =~ "issueUpdate" -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "take", "CRY-1"]) + end) + + assert_received {:input, input} + assert input == %{"assigneeId" => "u1"} + refute Map.has_key?(input, "stateId") + end + + test "multiple issues from different teams resolve status independently" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + variables = decoded["variables"] || %{} + + cond do + query =~ "viewer" -> + Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) + + query =~ "issue(id: $id)" and variables["id"] == "CRY-1" -> + Req.Test.json( + conn, + %{"data" => %{"issue" => take_issue_map(%{"team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}})}} + ) + + query =~ "issue(id: $id)" and variables["id"] == "CRY-2" -> + Req.Test.json( + conn, + %{ + "data" => %{ + "issue" => + take_issue_map(%{ + "id" => "i2", + "identifier" => "CRY-2", + "team" => %{"id" => "t2", "key" => "OPS", "name" => "Operations"} + }) + } + } + ) + + query =~ "states {" and variables["teamId"] == "t1" -> + Req.Test.json(conn, workflow_states([state_map("s-eng-todo", "Todo", 0.0, "unstarted")])) + + query =~ "states {" and variables["teamId"] == "t2" -> + Req.Test.json(conn, workflow_states([state_map("s-ops-todo", "Todo", 0.0, "unstarted")])) + + query =~ "issueUpdate" -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "take", "--status", "Todo", "CRY-1", "CRY-2"]) + end) + + assert_received {:input, input1} + assert_received {:input, input2} + + state_ids = MapSet.new([input1["stateId"], input2["stateId"]]) + assert MapSet.member?(state_ids, "s-eng-todo") + assert MapSet.member?(state_ids, "s-ops-todo") + end + + test "--status with space in name works on take" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + cond do + query =~ "viewer" -> + Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) + + query =~ "issue(id: $id)" -> + Req.Test.json(conn, %{"data" => %{"issue" => take_issue_map()}}) + + query =~ "states {" -> + Req.Test.json( + conn, + workflow_states([state_map("s-ip", "In Progress", 1.0, "started")]) + ) + + query =~ "issueUpdate" -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "take", "--status", "In Progress", "CRY-53"]) + end) + + assert_received {:input, input} + assert input["stateId"] == "s-ip" + end end end diff --git a/app/test/linear_cli/cli/issue_helpers_test.exs b/app/test/linear_cli/cli/issue_helpers_test.exs index 5c3603e..0015cdf 100644 --- a/app/test/linear_cli/cli/issue_helpers_test.exs +++ b/app/test/linear_cli/cli/issue_helpers_test.exs @@ -614,6 +614,235 @@ defmodule LinearCli.CLI.IssueHelpersTest do IssueHelpers.gimme_da_issue!("CRY-1", me: me) end) =~ "Assigning issue CRY-1 to ya" end + + test "with status: opt, resolves state per team and sends stateId" do + stub_responses([ + {"issue(id: $id)", + %{ + "data" => %{ + "issue" => %{ + "id" => "i1", + "identifier" => "CRY-1", + "title" => "Fix the thing", + "branchName" => "cry-1-fix-the-thing", + "description" => nil, + "assignee" => nil, + "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, + "comments" => %{"nodes" => []} + } + } + }}, + {"states {", + %{ + "data" => %{ + "team" => %{ + "states" => %{ + "nodes" => [ + %{"id" => "s1", "name" => "In Progress", "position" => 1.0, "type" => "started", + "description" => nil} + ] + } + } + } + }}, + {"issueUpdate", + issue_updated(%{ + "assignee" => %{ + "id" => "u1", + "name" => "Ada", + "email" => "ada@x.com", + "teams" => %{"nodes" => []} + } + })} + ]) + + me = %User{id: "u1", name: "Ada", email: "ada@x.com"} + + capture_io(fn -> + assert {:ok, %Issue{identifier: "CRY-1"}} = + IssueHelpers.gimme_da_issue!("CRY-1", me: me, status: "In Progress") + end) + end + + test "with status: opt, case-insensitive match sends correct stateId" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{ + "data" => %{ + "issue" => %{ + "id" => "i1", + "identifier" => "CRY-1", + "title" => "Fix the thing", + "branchName" => "cry-1-fix-the-thing", + "description" => nil, + "assignee" => nil, + "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, + "comments" => %{"nodes" => []} + } + } + }) + + String.contains?(query, "states {") -> + Req.Test.json(conn, %{ + "data" => %{ + "team" => %{ + "states" => %{ + "nodes" => [ + %{"id" => "s99", "name" => "Todo", "position" => 0.0, "type" => "unstarted", + "description" => nil} + ] + } + } + } + }) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:input, decoded["variables"]["input"]}) + + Req.Test.json(conn, issue_updated(%{ + "assignee" => %{ + "id" => "u1", + "name" => "Ada", + "email" => "ada@x.com", + "teams" => %{"nodes" => []} + } + })) + + true -> + raise "no stub matched: #{query}" + end + end) + + me = %User{id: "u1", name: "Ada", email: "ada@x.com"} + + capture_io(fn -> + assert {:ok, _} = IssueHelpers.gimme_da_issue!("CRY-1", me: me, status: "todo") + end) + + assert_received {:input, input} + assert input["stateId"] == "s99" + end + + test "with status: opt, already-assigned still sends stateId mutation" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{ + "data" => %{ + "issue" => %{ + "id" => "i1", + "identifier" => "CRY-1", + "title" => "Fix the thing", + "branchName" => "cry-1-fix-the-thing", + "description" => nil, + "assignee" => %{ + "id" => "u1", + "name" => "Ada", + "email" => "ada@x.com", + "teams" => %{"nodes" => []} + }, + "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, + "comments" => %{"nodes" => []} + } + } + }) + + String.contains?(query, "states {") -> + Req.Test.json(conn, %{ + "data" => %{ + "team" => %{ + "states" => %{ + "nodes" => [ + %{"id" => "s2", "name" => "In Progress", "position" => 1.0, "type" => "started", + "description" => nil} + ] + } + } + } + }) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:input, decoded["variables"]["input"]}) + + Req.Test.json(conn, issue_updated(%{ + "assignee" => %{ + "id" => "u1", + "name" => "Ada", + "email" => "ada@x.com", + "teams" => %{"nodes" => []} + } + })) + + true -> + raise "no stub matched: #{query}" + end + end) + + me = %User{id: "u1", name: "Ada", email: "ada@x.com"} + + capture_io(fn -> + assert {:ok, _} = IssueHelpers.gimme_da_issue!("CRY-1", me: me, status: "In Progress") + end) + + assert_received {:input, input} + assert input["assigneeId"] == "u1" + assert input["stateId"] == "s2" + end + + test "with status: opt, unknown name returns smells_bad error" do + stub_responses([ + {"issue(id: $id)", + %{ + "data" => %{ + "issue" => %{ + "id" => "i1", + "identifier" => "CRY-1", + "title" => "Fix the thing", + "branchName" => "cry-1-fix-the-thing", + "description" => nil, + "assignee" => nil, + "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, + "comments" => %{"nodes" => []} + } + } + }}, + {"states {", + %{ + "data" => %{ + "team" => %{ + "states" => %{ + "nodes" => [ + %{"id" => "s1", "name" => "Todo", "position" => 0.0, "type" => "unstarted", + "description" => nil} + ] + } + } + } + }} + ]) + + me = %User{id: "u1", name: "Ada", email: "ada@x.com"} + + capture_io(fn -> + assert {:error, {:smells_bad, msg}} = + IssueHelpers.gimme_da_issue!("CRY-1", me: me, status: "NoSuch") + + assert msg =~ "Unknown status" + end) + end end describe "create_pr!/3 and issue_pr/2" do diff --git a/documents/ash-domain-erd.adoc b/documents/ash-domain-erd.adoc index 31b4691..6294102 100644 --- a/documents/ash-domain-erd.adoc +++ b/documents/ash-domain-erd.adoc @@ -326,7 +326,7 @@ manual-implementation module, and the Linear GraphQL operation it calls. | `:assign` | update | `Linear.Issue.Update.Assign` -| `issueUpdate(id:, input: { assigneeId })` via `Issue.Update.run/2` +| `issueUpdate(id:, input: { assigneeId, stateId? })` via `Issue.Update.run/2` | `Issue` | `attach_issue_to_project` From a93209ee1f8e12235ae412226fe83e832ddb6c0b Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Tue, 18 Aug 2026 19:42:17 -0400 Subject: [PATCH 2/3] style: apply mix format to issue_commands_test.exs Elixir formatter prefers |> on its own line; auto-fixed via mix format. Co-Authored-By: Claude Sonnet 4.6 --- .../linear_cli/cli/issue_commands_test.exs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index 11c937d..5fd7f0e 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -1350,9 +1350,11 @@ defmodule LinearCli.CLI.IssueCommandsTest do String.contains?(query, "issueUpdate") -> send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json( conn, - issue_assigned(member_map("u2", "Bob")) |> put_in( + issue_assigned(member_map("u2", "Bob")) + |> put_in( ["data", "issueUpdate", "issue", "state"], %{"id" => "s2", "name" => "In Progress", "type" => "started"} ) @@ -1547,7 +1549,8 @@ defmodule LinearCli.CLI.IssueCommandsTest do String.contains?(query, "issueUpdate") -> Req.Test.json( conn, - issue_assigned(member_map("u2", "Bob")) |> put_in( + issue_assigned(member_map("u2", "Bob")) + |> put_in( ["data", "issueUpdate", "issue", "state"], %{"id" => "s1", "name" => "Todo", "type" => "unstarted"} ) @@ -1847,7 +1850,14 @@ defmodule LinearCli.CLI.IssueCommandsTest do query =~ "issue(id: $id)" and variables["id"] == "CRY-1" -> Req.Test.json( conn, - %{"data" => %{"issue" => take_issue_map(%{"team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}})}} + %{ + "data" => %{ + "issue" => + take_issue_map(%{ + "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"} + }) + } + } ) query =~ "issue(id: $id)" and variables["id"] == "CRY-2" -> @@ -1866,10 +1876,16 @@ defmodule LinearCli.CLI.IssueCommandsTest do ) query =~ "states {" and variables["teamId"] == "t1" -> - Req.Test.json(conn, workflow_states([state_map("s-eng-todo", "Todo", 0.0, "unstarted")])) + Req.Test.json( + conn, + workflow_states([state_map("s-eng-todo", "Todo", 0.0, "unstarted")]) + ) query =~ "states {" and variables["teamId"] == "t2" -> - Req.Test.json(conn, workflow_states([state_map("s-ops-todo", "Todo", 0.0, "unstarted")])) + Req.Test.json( + conn, + workflow_states([state_map("s-ops-todo", "Todo", 0.0, "unstarted")]) + ) query =~ "issueUpdate" -> send(test_pid, {:input, decoded["variables"]["input"]}) From ec13598afecfc225c1a770d4a5b5b45f3485bc15 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Tue, 18 Aug 2026 19:45:50 -0400 Subject: [PATCH 3/3] style: apply mix format to issue_helpers_test.exs Co-Authored-By: Claude Sonnet 4.6 --- .../linear_cli/cli/issue_helpers_test.exs | 74 +++++++++++++------ 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/app/test/linear_cli/cli/issue_helpers_test.exs b/app/test/linear_cli/cli/issue_helpers_test.exs index 0015cdf..9be1c11 100644 --- a/app/test/linear_cli/cli/issue_helpers_test.exs +++ b/app/test/linear_cli/cli/issue_helpers_test.exs @@ -638,8 +638,13 @@ defmodule LinearCli.CLI.IssueHelpersTest do "team" => %{ "states" => %{ "nodes" => [ - %{"id" => "s1", "name" => "In Progress", "position" => 1.0, "type" => "started", - "description" => nil} + %{ + "id" => "s1", + "name" => "In Progress", + "position" => 1.0, + "type" => "started", + "description" => nil + } ] } } @@ -695,8 +700,13 @@ defmodule LinearCli.CLI.IssueHelpersTest do "team" => %{ "states" => %{ "nodes" => [ - %{"id" => "s99", "name" => "Todo", "position" => 0.0, "type" => "unstarted", - "description" => nil} + %{ + "id" => "s99", + "name" => "Todo", + "position" => 0.0, + "type" => "unstarted", + "description" => nil + } ] } } @@ -706,14 +716,17 @@ defmodule LinearCli.CLI.IssueHelpersTest do String.contains?(query, "issueUpdate") -> send(test_pid, {:input, decoded["variables"]["input"]}) - Req.Test.json(conn, issue_updated(%{ - "assignee" => %{ - "id" => "u1", - "name" => "Ada", - "email" => "ada@x.com", - "teams" => %{"nodes" => []} - } - })) + Req.Test.json( + conn, + issue_updated(%{ + "assignee" => %{ + "id" => "u1", + "name" => "Ada", + "email" => "ada@x.com", + "teams" => %{"nodes" => []} + } + }) + ) true -> raise "no stub matched: #{query}" @@ -766,8 +779,13 @@ defmodule LinearCli.CLI.IssueHelpersTest do "team" => %{ "states" => %{ "nodes" => [ - %{"id" => "s2", "name" => "In Progress", "position" => 1.0, "type" => "started", - "description" => nil} + %{ + "id" => "s2", + "name" => "In Progress", + "position" => 1.0, + "type" => "started", + "description" => nil + } ] } } @@ -777,14 +795,17 @@ defmodule LinearCli.CLI.IssueHelpersTest do String.contains?(query, "issueUpdate") -> send(test_pid, {:input, decoded["variables"]["input"]}) - Req.Test.json(conn, issue_updated(%{ - "assignee" => %{ - "id" => "u1", - "name" => "Ada", - "email" => "ada@x.com", - "teams" => %{"nodes" => []} - } - })) + Req.Test.json( + conn, + issue_updated(%{ + "assignee" => %{ + "id" => "u1", + "name" => "Ada", + "email" => "ada@x.com", + "teams" => %{"nodes" => []} + } + }) + ) true -> raise "no stub matched: #{query}" @@ -825,8 +846,13 @@ defmodule LinearCli.CLI.IssueHelpersTest do "team" => %{ "states" => %{ "nodes" => [ - %{"id" => "s1", "name" => "Todo", "position" => 0.0, "type" => "unstarted", - "description" => nil} + %{ + "id" => "s1", + "name" => "Todo", + "position" => 0.0, + "type" => "unstarted", + "description" => nil + } ] } }