diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 3960e3f..a717b10 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -100,6 +100,7 @@ defmodule LinearCli.CLI do "dev" => "develop", "l" => "list", "ls" => "list", + "s" => "status", "u" => "update", "pull-request" => "pr" }, @@ -203,6 +204,7 @@ defmodule LinearCli.CLI do defp dispatch([:issue, :pr], result, halt), do: run(&Commands.issue_pr/1, result, halt) defp dispatch([:issue, :take], result, halt), do: run(&Commands.issue_take/1, result, halt) + defp dispatch([:issue, :status], result, halt), do: run(&Commands.issue_status/1, result, halt) defp dispatch([:issue, :update], result, halt), do: run(&Commands.issue_update/1, result, halt) # A valid subcommand path that stops short of a leaf (e.g. `lc project` @@ -594,6 +596,25 @@ defmodule LinearCli.CLI do description: [long: "--description", help: "The description of the PR"] ] ], + status: [ + name: "status", + about: "Change the workflow state of an issue", + args: [ + issue_id: [value_name: "ISSUE_ID", help: "The Issue (i.e. CRY-1)", required: true] + ], + options: [ + status: [ + short: "-s", + long: "--status", + help: "Workflow state name to set (prompts if omitted)" + ], + comment: [ + short: "-m", + long: "--comment", + help: "Comment to add alongside the status change" + ] + ] + ], take: [ name: "take", about: "Assign one or more issues to yourself", diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 7aa214b..760f016 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -475,4 +475,72 @@ defmodule LinearCli.CLI.Commands do defp validate_issue_ids([]), do: {:error, {:smells_bad, "No issue IDs provided!"}} defp validate_issue_ids(_issue_ids), do: :ok + + @doc """ + Changes the workflow state of an issue. + + With `--status`/`-s`, matches the given name against the issue's team's + workflow states (case-insensitive exact, then unique prefix). Without it, + prompts interactively via `LinearCli.CLI.Prompt.select/2`. + + With `--comment`/`-m`, adds a comment to the issue before transitioning. + """ + @spec issue_status(Optimus.ParseResult.t()) :: :ok | {:error, term()} + def issue_status(%{args: %{issue_id: issue_id}, options: options}) do + expanded_id = IssueHelpers.expand_issue_id(issue_id) + + with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}), + {:ok, states} <- Linear.workflow_states_by_team(issue.team.id), + {:ok, target_state} <- resolve_target_state(states, options.status), + :ok <- maybe_add_status_comment(issue, options.comment), + {:ok, updated} <- Linear.set_issue_status(issue, target_state.id) do + Display.show(updated, %{output: options.output}) + + if options.output != "json", + do: Prompt.ok("#{updated.identifier} status set to #{target_state.name}") + + :ok + end + end + + defp resolve_target_state(states, nil) do + choices = Enum.sort_by(states, & &1.position) |> Enum.map(&{&1.name, &1}) + {:ok, Prompt.select("Choose a status", choices)} + end + + defp resolve_target_state(states, name) do + normalized_name = String.downcase(name) + + states + |> Enum.filter(&(String.downcase(&1.name) == normalized_name)) + |> use_prefix_matches_if_empty(states, normalized_name) + |> resolve_state_matches(states, name) + end + + defp use_prefix_matches_if_empty([], states, name) do + Enum.filter(states, &String.starts_with?(String.downcase(&1.name), name)) + end + + defp use_prefix_matches_if_empty(matches, _states, _name), do: matches + + defp resolve_state_matches([state], _states, _name), do: {:ok, state} + + defp resolve_state_matches([], states, name) do + available = Enum.map_join(states, ", ", & &1.name) + {:error, {:smells_bad, "Unknown status #{inspect(name)}. Available: #{available}"}} + end + + defp resolve_state_matches(matches, _states, name) do + ambiguous = Enum.map_join(matches, ", ", & &1.name) + {:error, {:smells_bad, "Ambiguous status #{inspect(name)}: matches #{ambiguous}"}} + end + + defp maybe_add_status_comment(_issue, nil), do: :ok + + defp maybe_add_status_comment(issue, comment) do + case IssueHelpers.issue_comment(issue, comment) do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end end diff --git a/app/lib/linear_cli/linear.ex b/app/lib/linear_cli/linear.ex index 87883c8..ded07c0 100644 --- a/app/lib/linear_cli/linear.ex +++ b/app/lib/linear_cli/linear.ex @@ -35,6 +35,7 @@ defmodule LinearCli.Linear do define :assign_issue, action: :assign, args: [:assignee_id] define :attach_issue_to_project, action: :attach_to_project, args: [:project_id] define :close_issue, action: :close, args: [:state_id] + define :set_issue_status, action: :set_status, args: [:state_id] end resource LinearCli.Linear.Label do diff --git a/app/lib/linear_cli/linear/issue.ex b/app/lib/linear_cli/linear/issue.ex index 63df9c6..269aa3f 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -48,6 +48,11 @@ defmodule LinearCli.Linear.Issue do argument :trash, :boolean, default: false manual LinearCli.Linear.Issue.Update.Close end + + update :set_status do + argument :state_id, :string, allow_nil?: false + manual LinearCli.Linear.Issue.Update.SetStatus + end end attributes do @@ -360,3 +365,16 @@ defmodule LinearCli.Linear.Issue.Update.Close do Issue.Update.run(changeset.data.identifier, input) end end + +defmodule LinearCli.Linear.Issue.Update.SetStatus do + @moduledoc false + use Ash.Resource.ManualUpdate + + alias LinearCli.Linear.Issue + + def update(changeset, _opts, _context) do + Issue.Update.run(changeset.data.identifier, %{ + "stateId" => changeset.arguments.state_id + }) + 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 dfa6892..e123118 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -536,6 +536,352 @@ defmodule LinearCli.CLI.IssueCommandsTest do end end + describe "issue status" do + defp state_map(id, name, position, type) do + %{"id" => id, "name" => name, "position" => position, "type" => type, "description" => nil} + end + + defp issue_with_state(state_id, state_name) do + issue_map(%{"state" => %{"id" => state_id, "name" => state_name, "type" => "started"}}) + end + + defp states_response do + workflow_states([ + state_map("s1", "Triage", 0.0, "triage"), + state_map("s2", "In Progress", 1.0, "started"), + state_map("s3", "Done", 2.0, "completed") + ]) + end + + test "--status sets the workflow state by exact name (case-insensitive)" do + test_pid = self() + + 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, "states {") -> + Req.Test.json(conn, states_response()) + + String.contains?(query, "issueUpdate") -> + body_decoded = Jason.decode!(body) + send(test_pid, {:state_id, body_decoded["variables"]["input"]["stateId"]}) + + Req.Test.json(conn, %{ + "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "status", "--status", "done", "CRY-1"]) + end) + + assert_received {:state_id, "s3"} + assert output =~ "CRY-1" + assert output =~ "status set to Done" + end + + test "-s short flag also sets the workflow state" do + test_pid = self() + + 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, "states {") -> + Req.Test.json(conn, states_response()) + + String.contains?(query, "issueUpdate") -> + body_decoded = Jason.decode!(body) + send(test_pid, {:state_id, body_decoded["variables"]["input"]["stateId"]}) + + Req.Test.json(conn, %{ + "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "status", "-s", "Done", "CRY-1"]) + end) + + assert_received {:state_id, "s3"} + assert output =~ "status set to Done" + end + + test "--status with prefix match selects unique match" do + test_pid = self() + + 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, "states {") -> + Req.Test.json(conn, states_response()) + + String.contains?(query, "issueUpdate") -> + body_decoded = Jason.decode!(body) + send(test_pid, {:state_id, body_decoded["variables"]["input"]["stateId"]}) + + Req.Test.json(conn, %{ + "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s2", "In Progress")}} + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "status", "--status", "in", "CRY-1"]) + end) + + assert_received {:state_id, "s2"} + assert output =~ "status set to In Progress" + end + + test "--status with unknown name exits 22 (smells bad)" 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, "states {") -> + Req.Test.json(conn, states_response()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + stderr = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "status", "--status", "Nonexistent", "CRY-1"], halt) + end) + + assert_received {:halted, 22} + assert stderr =~ "Unknown status" + assert stderr =~ "This smells bad! Bailing." + end + + test "--status with ambiguous prefix exits 22 (smells bad)" 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, "states {") -> + # Two states starting with "D" to trigger ambiguity + Req.Test.json( + conn, + workflow_states([ + state_map("s1", "Done", 1.0, "completed"), + state_map("s2", "Doing", 2.0, "started") + ]) + ) + + true -> + raise "no stub matched query: #{query}" + end + end) + + stderr = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "status", "--status", "Do", "CRY-1"], halt) + end) + + assert_received {:halted, 22} + assert stderr =~ "Ambiguous status" + assert stderr =~ "This smells bad! Bailing." + end + + test "--comment adds a comment before changing the status" do + test_pid = self() + + 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, "states {") -> + Req.Test.json(conn, states_response()) + + String.contains?(query, "commentCreate") -> + send(test_pid, :comment_created) + Req.Test.json(conn, comment_created()) + + String.contains?(query, "issueUpdate") -> + Req.Test.json(conn, %{ + "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "status", + "--status", + "Done", + "--comment", + "Wrapping up", + "CRY-1" + ]) + end) + + assert_received :comment_created + assert output =~ "Comment added to CRY-1" + assert output =~ "status set to Done" + end + + test "interactive selection (no --status) prompts from sorted states" 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, "states {") -> + Req.Test.json(conn, states_response()) + + String.contains?(query, "issueUpdate") -> + Req.Test.json(conn, %{ + "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + # Select the third option ("Done") interactively via stdin + output = + capture_io([input: "3\n"], fn -> + assert :ok = LinearCli.CLI.main(["issue", "status", "CRY-1"]) + end) + + assert output =~ "Choose a status" + assert output =~ "status set to Done" + end + + test "--output json emits structured output" 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, "states {") -> + Req.Test.json(conn, states_response()) + + String.contains?(query, "issueUpdate") -> + Req.Test.json(conn, %{ + "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "status", + "--status", + "Done", + "--output", + "json", + "CRY-1" + ]) + end) + + assert {:ok, decoded} = Jason.decode(output) + assert decoded["identifier"] == "CRY-1" + end + + test "alias 's' routes to issue status" do + test_pid = self() + + 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, "states {") -> + Req.Test.json(conn, states_response()) + + String.contains?(query, "issueUpdate") -> + send(test_pid, :updated) + + Req.Test.json(conn, %{ + "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "s", "--status", "Done", "CRY-1"]) + end) + + assert_received :updated + end + end + describe "issue update (Ruby: commands/issue/update.rb)" do test "--close comments with the given reason, then closes the issue" do stub_responses([