Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions Readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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>
----
Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions app/lib/linear_cli/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
]
]
Expand Down
1 change: 1 addition & 0 deletions app/lib/linear_cli/cli/commands.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
]

Expand Down
64 changes: 36 additions & 28 deletions app/lib/linear_cli/cli/issue_helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
"""
Expand All @@ -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}
Expand All @@ -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
Expand All @@ -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}")
Expand All @@ -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
Expand All @@ -190,40 +191,47 @@ 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
`CLI::WhatFor#completed_state_for` - see this module's moduledoc. Returns
`{: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 """
Expand Down
61 changes: 50 additions & 11 deletions app/test/linear_cli/cli/issue_commands_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down