diff --git a/Readme.adoc b/Readme.adoc index 3d935ee..f952fb5 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -267,8 +267,8 @@ All of the update options can work on multiple issues at a time. [source,sh] ---- $ lc issue update --comment "Here is a comment" CRY-1234 <1> -$ lc issue update --close --reason "I do not like you" CRY-14 CRY-15 <2> -$ lc issue update --cancel --trash --reason "I have no idea why you are here" CRY-16 CRY-17 <3> +$ lc issue update --close --status Done --reason "I do not like you" CRY-14 CRY-15 <2> +$ lc issue update --cancel --status Cancelled --trash --reason "I have no idea why you are here" CRY-16 CRY-17 <3> $ lc issue update --comment - CRY-14 CRY-15 <4> $ lcomment CRY-1234 CRY-3 <5> ---- @@ -280,9 +280,13 @@ $ lcomment CRY-1234 CRY-3 <5> ===== Close one or many issues +Use `--status`/`-s` to choose the completed or cancelled workflow state by +case-insensitive name or unique prefix. Omit it to select interactively when +the team has more than one matching state. + [source,sh] ---- -$ lc issue update --close --reason "These were closable" CRY-1234 CRY-2 +$ lc issue update --close --status Done --reason "These were closable" CRY-1234 CRY-2 ---- ==== Default team/project (profiles) diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index c3b1b32..e7e7bee 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -766,6 +766,11 @@ defmodule LinearCli.CLI do long: "--project", help: "Project to move the issue to. - select from a list" ], + status: [ + short: "-s", + long: "--status", + help: "Workflow state name to use with --close or --cancel" + ], reason: [long: "--reason", help: "Reason for closing the issue. - open an editor"] ] ] diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index b9f25e3..dcc802e 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -500,6 +500,7 @@ defmodule LinearCli.CLI.Commands do cancel: flags.cancel, close: flags.close, reason: options.reason, + status: Map.get(options, :status), trash: flags.trash ] diff --git a/app/lib/linear_cli/cli/issue_helpers.ex b/app/lib/linear_cli/cli/issue_helpers.ex index 7909ca0..78ca577 100644 --- a/app/lib/linear_cli/cli/issue_helpers.ex +++ b/app/lib/linear_cli/cli/issue_helpers.ex @@ -112,10 +112,11 @@ defmodule LinearCli.CLI.IssueHelpers do Cancels `issue`: comments with a resolved reason, then transitions it to its team's cancelled workflow state. - `opts` (Ruby's `**options`): + `opts` (Ruby's `**options`, plus this port's `:status`): * `:reason` - passed through to `LinearCli.CLI.WhatFor.reason_for/2` - * `:trash` - forwarded to the `issueUpdate` mutation's `trashed:` input + * `:status` - cancelled workflow state name (exact or unique prefix) + * `:trash` - trashes the transitioned issue through `issueArchive` Ported from `CLI::Issue#cancel_issue`. """ @@ -129,7 +130,7 @@ defmodule LinearCli.CLI.IssueHelpers do WhatFor.reason_for(opts[:reason], four: "cancelling #{issue.identifier} - #{issue.title}") with {:ok, _comment} <- issue_comment(issue, reason), - {:ok, cancel_state} <- cancelled_state_for(issue), + {:ok, cancel_state} <- cancelled_state_for(issue, opts[:status]), {:ok, updated} <- Linear.close_issue(issue, cancel_state.id, %{trash: !!opts[:trash]}) do Prompt.ok("#{issue.identifier} was cancelled") {:ok, updated} @@ -141,8 +142,8 @@ defmodule LinearCli.CLI.IssueHelpers do Closes (or, if `opts[:cancel]` is truthy, cancels) `issue`: comments with a resolved reason, then transitions it to the appropriate workflow state. - `opts` (Ruby's `**options`): `:cancel`, `:reason`, `:trash` - same meaning - as `cancel_issue/2`'s. + `opts` (Ruby's `**options`, plus this port's `:status`): `:cancel`, + `:reason`, `:status`, `:trash` - same meaning as `cancel_issue/2`'s. Ported from `CLI::Issue#close_issue`. Note this has its own internal cancelled/completed branch (mirroring Ruby exactly) even though @@ -166,7 +167,7 @@ defmodule LinearCli.CLI.IssueHelpers do WhatFor.reason_for(opts[:reason], four: "#{doing} *#{issue.identifier} - #{issue.title}*") with {:ok, _comment} <- issue_comment(issue, reason), - {:ok, workflow_state} <- state_for(cancelled, issue), + {:ok, workflow_state} <- state_for(cancelled, issue, opts[:status]), {:ok, updated} <- Linear.close_issue(issue, workflow_state.id, %{trash: !!opts[:trash]}) do Prompt.ok("#{issue.identifier} was #{done}") @@ -175,13 +176,13 @@ defmodule LinearCli.CLI.IssueHelpers do end end - defp state_for(cancelled, issue) do - if cancelled, do: cancelled_state_for(issue), else: completed_state_for(issue) - end + defp state_for(true, issue, status), do: cancelled_state_for(issue, status) + defp state_for(_cancelled, issue, status), do: completed_state_for(issue, status) @doc """ Resolves `issue`'s team's single cancelled workflow state directly, or - prompts (`LinearCli.CLI.Prompt.select/2`) among several. + prompts (`LinearCli.CLI.Prompt.select/2`) among several. When `status` is + given, selects by case-insensitive exact name or unique prefix instead. Ported from the combination of Ruby's `BaseModel#cancelled_states` (`workflow_states.select { |ws| CANCELLED_STATES.include? ws.type }`) and @@ -190,14 +191,15 @@ defmodule LinearCli.CLI.IssueHelpers do *no* cancelled-type workflow state - Ruby has no equivalent guard (its own `states.first` on an empty array is silently `nil`). """ - @spec cancelled_state_for(%Linear.Issue{}) :: + @spec cancelled_state_for(%Linear.Issue{}, String.t() | nil) :: {:ok, %Linear.WorkflowState{}} | {:error, term()} - def cancelled_state_for(issue), - do: workflow_state_for(issue, ["cancelled", "canceled"], "cancelled") + def cancelled_state_for(issue, status \\ nil), + do: workflow_state_for(issue, ["cancelled", "canceled"], "cancelled", status) @doc """ Resolves `issue`'s team's single completed workflow state directly, or - prompts (`LinearCli.CLI.Prompt.select/2`) among several. + prompts (`LinearCli.CLI.Prompt.select/2`) among several. When `status` is + given, selects by case-insensitive exact name or unique prefix instead. Ported from the combination of Ruby's `BaseModel#completed_states` (`workflow_states.select { |ws| ws.type == 'completed' }`) and @@ -205,25 +207,31 @@ defmodule LinearCli.CLI.IssueHelpers do `{:error, {:smells_bad, message}}` if the team has no completed-type workflow state. """ - @spec completed_state_for(%Linear.Issue{}) :: + @spec completed_state_for(%Linear.Issue{}, String.t() | nil) :: {:ok, %Linear.WorkflowState{}} | {:error, term()} - def completed_state_for(issue), do: workflow_state_for(issue, ["completed"], "completed") + def completed_state_for(issue, status \\ nil), + do: workflow_state_for(issue, ["completed"], "completed", status) - defp workflow_state_for(issue, types, label) do + defp workflow_state_for(issue, types, label, status) do with {:ok, states} <- Linear.workflow_states_by_team(issue.team.id) do - case Enum.filter(states, &(&1.type in types)) do - [state] -> - {:ok, state} + states + |> Enum.filter(&(&1.type in types)) + |> select_workflow_state(issue, label, status) + end + end - [] -> - smells_bad( - "No #{label} workflow states found for team #{issue.team.key || issue.team.id}" - ) + defp select_workflow_state([], issue, label, _status) do + smells_bad("No #{label} workflow states found for team #{issue.team.key || issue.team.id}") + end - many -> - {:ok, Prompt.select("Choose a #{label} state", Enum.map(many, &{&1.name, &1}))} - end - end + defp select_workflow_state([state], _issue, _label, nil), do: {:ok, state} + + defp select_workflow_state(states, _issue, label, nil) do + {:ok, Prompt.select("Choose a #{label} state", Enum.map(states, &{&1.name, &1}))} + end + + defp select_workflow_state(states, _issue, _label, status) do + resolve_workflow_state(states, status) end @doc """ diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index c41940e..d65c31c 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -1060,25 +1060,64 @@ defmodule LinearCli.CLI.IssueCommandsTest do end describe "issue update (Ruby: commands/issue/update.rb)" do - test "--close comments with the given reason, then closes the issue" do - stub_responses([ - {"issue(id: $id)", %{"data" => %{"issue" => issue_map()}}}, - {"commentCreate", comment_created()}, - {"states {", - workflow_states([ - %{"id" => "s1", "name" => "Done", "position" => 1.0, "type" => "completed"} - ])}, - {"issueUpdate", issue_updated()} - ]) + test "--close --status selects a completed state without prompting" 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "commentCreate") -> + Req.Test.json(conn, comment_created()) + + String.contains?(query, "states {") -> + Req.Test.json( + conn, + workflow_states([ + %{"id" => "s1", "name" => "Done", "position" => 1.0, "type" => "completed"}, + %{ + "id" => "s2", + "name" => "Shipped", + "position" => 2.0, + "type" => "completed" + } + ]) + ) + + String.contains?(query, "issueUpdate") -> + assert decoded["variables"]["input"] == %{"stateId" => "s2"} + send(test_pid, :closed_as_shipped) + Req.Test.json(conn, issue_updated()) + + true -> + raise "no stub matched query: #{query}" + end + end) output = capture_io(fn -> assert :ok = - LinearCli.CLI.main(["issue", "update", "--close", "--reason", "Done", "CRY-1"]) + LinearCli.CLI.main([ + "issue", + "update", + "--close", + "--status", + "ship", + "--reason", + "Done", + "CRY-1" + ]) end) assert output =~ "Comment added to CRY-1" assert output =~ "CRY-1 was closed" + refute output =~ "Choose a completed state" + assert_received :closed_as_shipped end test "--description updates the issue description via the issueUpdate mutation" do