Skip to content

Commit 37c1940

Browse files
authored
feat: allow multiple concurrent status updates for issues (#180)
- **feat: allows multiple issues to get status updates concurrently** - **fix: ensure mutations on issues happen concurrently**
1 parent aa572f6 commit 37c1940

3 files changed

Lines changed: 192 additions & 18 deletions

File tree

app/lib/linear_cli/cli.ex

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ defmodule LinearCli.CLI do
246246
end
247247
end
248248

249-
# `issue list`/`take`/`update` all set `allow_unknown_args: true` so bare
249+
# `issue list`/`take`/`status`/`update` all set `allow_unknown_args: true` so bare
250250
# tokens (e.g. `CRY-1`) can be captured as issue ids via `result.unknown`
251251
# rather than a declared positional arg (Optimus has no `type: :array`
252252
# equivalent - see their subcommand specs below). That same bucket also
@@ -256,7 +256,7 @@ defmodule LinearCli.CLI do
256256
# clearly. Every other subcommand has `allow_unknown_args: false` (the
257257
# default), where Optimus itself already rejects unknown args before we
258258
# ever see a parse_result - so `result.unknown` is only ever non-empty here
259-
# for those three subcommands, and only ever contains genuine bare ids
259+
# for those four subcommands, and only ever contains genuine bare ids
260260
# once this filters out anything flag-shaped.
261261
defp reject_unknown_flags(unknown_tokens) do
262262
case Enum.filter(unknown_tokens, &String.starts_with?(&1, "-")) do
@@ -737,10 +737,8 @@ defmodule LinearCli.CLI do
737737
],
738738
status: [
739739
name: "status",
740-
about: "Change the workflow state of an issue",
741-
args: [
742-
issue_id: [value_name: "ISSUE_ID", help: "The Issue (i.e. CRY-1)", required: true]
743-
],
740+
about: "Change workflow state (ISSUE_ID...)",
741+
allow_unknown_args: true,
744742
options: [
745743
status: [
746744
short: "-s",

app/lib/linear_cli/cli/commands.ex

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ defmodule LinearCli.CLI.Commands do
77
alias LinearCli.CLI.{Display, IssueHelpers, Projects, Prompt, WhatFor}
88
alias LinearCli.{Favorites, Git, Linear, Profiles}
99

10+
@max_concurrent_issue_updates 20
11+
1012
@doc "Ported from commands/whoami.rb."
1113
def whoami(%{flags: flags, options: options}) do
1214
with {:ok, user} <- Linear.me() do
@@ -518,32 +520,91 @@ defmodule LinearCli.CLI.Commands do
518520
defp validate_issue_ids(_issue_ids), do: :ok
519521

520522
@doc """
521-
Changes the workflow state of an issue.
523+
Changes the workflow state of one or more issues. Optimus captures the IDs in
524+
`unknown`, since it has no variadic positional-argument type.
522525
523526
With `--status`/`-s`, matches the given name against the issue's team's
524527
workflow states (case-insensitive exact, then unique prefix). Without it,
525528
prompts interactively via `LinearCli.CLI.Prompt.select/2`.
526529
527-
With `--comment`/`-m`, adds a comment to the issue before transitioning.
530+
With `--comment`/`-m`, adds a comment to each issue before transitioning it.
531+
Mutations for separate issues run concurrently with a limit of 20 in flight.
528532
"""
529533
@spec issue_status(Optimus.ParseResult.t()) :: :ok | {:error, term()}
530-
def issue_status(%{args: %{issue_id: issue_id}, options: options}) do
531-
expanded_id = IssueHelpers.expand_issue_id(issue_id)
534+
def issue_status(%{unknown: issue_ids, options: options}) do
535+
with :ok <- validate_issue_ids(issue_ids),
536+
{:ok, issues} <-
537+
Linear.issues(%{ids: Enum.map(issue_ids, &IssueHelpers.expand_issue_id/1)}),
538+
{:ok, planned_updates} <- plan_status_updates(issues, options.status),
539+
{:ok, completed_updates} <- apply_status_updates(planned_updates, options.comment) do
540+
show_status_updates(completed_updates, options.output)
541+
end
542+
end
532543

533-
with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}),
534-
{:ok, states} <- Linear.workflow_states_by_team(issue.team.id),
535-
{:ok, target_state} <- resolve_target_state(states, options.status),
536-
:ok <- maybe_add_status_comment(issue, options.comment),
544+
defp plan_status_updates(issues, status) do
545+
issues
546+
|> Enum.reduce_while({:ok, []}, fn issue, {:ok, updates} ->
547+
with {:ok, states} <- Linear.workflow_states_by_team(issue.team.id),
548+
{:ok, target_state} <- resolve_target_state(states, status) do
549+
{:cont, {:ok, [{issue, target_state} | updates]}}
550+
else
551+
{:error, reason} -> {:halt, {:error, reason}}
552+
end
553+
end)
554+
|> reverse_status_updates()
555+
end
556+
557+
defp apply_status_updates([], _comment), do: {:ok, []}
558+
559+
defp apply_status_updates(planned_updates, comment) do
560+
planned_updates
561+
|> Task.async_stream(
562+
fn {issue, target_state} ->
563+
apply_status_update(issue, target_state, comment)
564+
end,
565+
max_concurrency: min(length(planned_updates), @max_concurrent_issue_updates),
566+
ordered: true,
567+
timeout: 30_000
568+
)
569+
|> Enum.reduce_while({:ok, []}, fn
570+
{:ok, {:ok, update}}, {:ok, updates} ->
571+
{:cont, {:ok, [update | updates]}}
572+
573+
{:ok, {:error, reason}}, {:ok, _updates} ->
574+
{:halt, {:error, reason}}
575+
576+
{:exit, reason}, {:ok, _updates} ->
577+
{:halt, {:error, {:task_exit, reason}}}
578+
end)
579+
|> reverse_status_updates()
580+
end
581+
582+
defp apply_status_update(issue, target_state, comment) do
583+
with :ok <- maybe_add_status_comment(issue, comment),
537584
{:ok, updated} <- Linear.set_issue_status(issue, target_state.id) do
538-
Display.show(updated, %{output: options.output})
585+
{:ok, {updated, target_state}}
586+
end
587+
end
539588

540-
if options.output != "json",
541-
do: Prompt.ok("#{updated.identifier} status set to #{target_state.name}")
589+
defp reverse_status_updates({:ok, updates}), do: {:ok, Enum.reverse(updates)}
590+
defp reverse_status_updates(error), do: error
542591

543-
:ok
592+
defp show_status_updates(completed_updates, output) do
593+
updated_issues = Enum.map(completed_updates, &elem(&1, 0))
594+
Display.show(one_or_many(updated_issues), %{output: output})
595+
596+
if output != "json" do
597+
Enum.each(completed_updates, fn {updated, target_state} ->
598+
Prompt.ok("#{updated.identifier} status set to #{target_state.name}")
599+
end)
544600
end
601+
602+
:ok
545603
end
546604

605+
defp one_or_many([one]), do: one
606+
defp one_or_many(many), do: many
607+
547608
defp resolve_target_state(states, nil) do
548609
choices = Enum.sort_by(states, & &1.position) |> Enum.map(&{&1.name, &1})
549610
{:ok, Prompt.select("Choose a status", choices)}

app/test/linear_cli/cli/issue_commands_test.exs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,121 @@ defmodule LinearCli.CLI.IssueCommandsTest do
775775
assert output =~ "status set to Done"
776776
end
777777

778+
test "--status updates multiple issue IDs concurrently and emits a JSON array" do
779+
test_pid = self()
780+
781+
issue_details = fn
782+
"CRY-1" -> {"i1", "t1", "ENG", "Engineering", "s-eng-done"}
783+
"CRY-2" -> {"i2", "t2", "OPS", "Operations", "s-ops-done"}
784+
end
785+
786+
Req.Test.stub(LinearCli.Api, fn conn ->
787+
{:ok, body, conn} = Plug.Conn.read_body(conn)
788+
decoded = Jason.decode!(body)
789+
%{"query" => query} = decoded
790+
variables = decoded["variables"] || %{}
791+
792+
cond do
793+
String.contains?(query, "issue(id: $id)") ->
794+
identifier = variables["id"]
795+
{id, team_id, team_key, team_name, _state_id} = issue_details.(identifier)
796+
797+
Req.Test.json(conn, %{
798+
"data" => %{
799+
"issue" =>
800+
issue_map(%{
801+
"id" => id,
802+
"identifier" => identifier,
803+
"team" => %{"id" => team_id, "key" => team_key, "name" => team_name}
804+
})
805+
}
806+
})
807+
808+
String.contains?(query, "states {") ->
809+
team_id = variables["teamId"]
810+
state_id = if team_id == "t1", do: "s-eng-done", else: "s-ops-done"
811+
send(test_pid, {:states_queried, team_id})
812+
Req.Test.json(conn, workflow_states([state_map(state_id, "Done", 1.0, "completed")]))
813+
814+
String.contains?(query, "issueUpdate") ->
815+
identifier = variables["id"]
816+
state_id = variables["input"]["stateId"]
817+
{id, team_id, team_key, team_name, ^state_id} = issue_details.(identifier)
818+
update_pid = self()
819+
send(test_pid, {:status_update_started, identifier, state_id, update_pid})
820+
821+
receive do
822+
:finish_status_update -> :ok
823+
after
824+
2_000 -> raise "status update was not released by the concurrency assertion"
825+
end
826+
827+
Req.Test.json(conn, %{
828+
"data" => %{
829+
"issueUpdate" => %{
830+
"issue" =>
831+
issue_map(%{
832+
"id" => id,
833+
"identifier" => identifier,
834+
"team" => %{"id" => team_id, "key" => team_key, "name" => team_name},
835+
"state" => %{"id" => state_id, "name" => "Done", "type" => "completed"}
836+
})
837+
}
838+
}
839+
})
840+
841+
true ->
842+
raise "no stub matched query: #{query}"
843+
end
844+
end)
845+
846+
command =
847+
Task.async(fn ->
848+
capture_io(fn ->
849+
assert :ok =
850+
LinearCli.CLI.main([
851+
"issue",
852+
"status",
853+
"--status",
854+
"Done",
855+
"--output",
856+
"json",
857+
"CRY-1",
858+
"CRY-2"
859+
])
860+
end)
861+
end)
862+
863+
assert_receive {:status_update_started, "CRY-1", "s-eng-done", first_update}, 1_000
864+
assert_receive {:status_update_started, "CRY-2", "s-ops-done", second_update}, 1_000
865+
send(first_update, :finish_status_update)
866+
send(second_update, :finish_status_update)
867+
868+
output = Task.await(command)
869+
870+
assert_received {:states_queried, "t1"}
871+
assert_received {:states_queried, "t2"}
872+
873+
assert {:ok, decoded} = Jason.decode(output)
874+
assert Enum.map(decoded, & &1["identifier"]) == ["CRY-1", "CRY-2"]
875+
end
876+
877+
test "variadic issue IDs do not swallow unrecognized options" do
878+
test_pid = self()
879+
halt = fn code -> send(test_pid, {:halted, code}) end
880+
881+
stderr =
882+
capture_io(:stderr, fn ->
883+
LinearCli.CLI.main(
884+
["issue", "status", "--statuz", "Done", "CRY-1", "CRY-2"],
885+
halt
886+
)
887+
end)
888+
889+
assert_received {:halted, 22}
890+
assert stderr =~ "unrecognized option(s): --statuz"
891+
end
892+
778893
test "-s short flag also sets the workflow state" do
779894
test_pid = self()
780895

0 commit comments

Comments
 (0)