From 1cf05fd222e39aa7f221c2236cc6a6b3f668ec44 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Thu, 3 Sep 2026 12:03:25 -0400 Subject: [PATCH] feat(issue-comment): accept multiple ISSUE_IDs Switch `lc issue comment` from a single required positional arg to `allow_unknown_args: true`, matching the pattern used by `issue take`, `issue status`, `issue update`, and `issue move`. When multiple IDs are given the same comment body is posted to each issue concurrently via Task.async_stream (capped at 20 in flight, same as other bulk-mutation commands). Body resolution (prompt, -m text, or --body-file) happens once before the fan-out, using the first issue's context for the interactive prompt. Co-Authored-By: Claude Sonnet 4.6 --- app/lib/linear_cli/cli.ex | 6 +-- app/lib/linear_cli/cli/commands.ex | 51 ++++++++++++++++--- .../linear_cli/cli/issue_commands_test.exs | 37 ++++++++++++++ 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 0d6ce1b..b279244 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -770,10 +770,8 @@ defmodule LinearCli.CLI do ], comment: [ name: "comment", - about: "Add a comment to an issue", - args: [ - issue_id: [value_name: "ISSUE_ID", help: "The Issue (i.e. CRY-1)", required: true] - ], + about: "Add a comment to one or more issues (ISSUE_ID...)", + allow_unknown_args: true, options: [ comment: [ short: "-m", diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 4bf1a82..c10e43a 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -556,7 +556,7 @@ defmodule LinearCli.CLI.Commands do end @doc """ - Adds a comment to a single issue. + Adds a comment to one or more issues (ISSUE_ID...). `--comment`/`-m` and `--body-file` are mutually exclusive. `--body-file` reads the body from a file (`-` for stdin) - the way to supply a large @@ -566,6 +566,10 @@ defmodule LinearCli.CLI.Commands do not protect against. Without either option, `comment_for/2`'s existing behavior applies (prompt, or open an editor for `-`). + When multiple issue IDs are given, the same comment body is posted to + each concurrently. The interactive prompt (when neither `-m` nor + `--body-file` is given) uses the first issue's context. + Calls `Linear.add_comment/2` directly rather than `LinearCli.CLI.IssueHelpers.issue_comment/2` so the confirmation can be suppressed under `--output json` - matching how `print_move_results/3` @@ -574,18 +578,49 @@ defmodule LinearCli.CLI.Commands do New in this port - Ruby has no equivalent. """ @spec issue_comment(Optimus.ParseResult.t()) :: :ok | {:error, term()} - def issue_comment(%{args: %{issue_id: issue_id}, options: options}) do - with :ok <- validate_body_file_exclusion(options, :comment, "--comment"), + def issue_comment(%{unknown: issue_ids, options: options}) do + with :ok <- validate_issue_ids(issue_ids), + :ok <- validate_body_file_exclusion(options, :comment, "--comment"), {:ok, comment_text} <- resolve_body_from_file(options, :comment), - {:ok, [issue]} <- Linear.issues(%{ids: [IssueHelpers.expand_issue_id(issue_id)]}), - body = WhatFor.comment_for(issue, comment_text), - {:ok, comment} <- Linear.add_comment(issue.identifier, body) do - unless options.output == "json", do: Prompt.ok("Comment added to #{issue.identifier}") - Display.show(comment, %{output: options.output}) + {:ok, issues} <- + Linear.issues(%{ids: Enum.map(issue_ids, &IssueHelpers.expand_issue_id/1)}), + body = WhatFor.comment_for(hd(issues), comment_text), + {:ok, pairs} <- add_comments_to_issues(issues, body) do + unless options.output == "json" do + Enum.each(pairs, fn {issue, _comment} -> + Prompt.ok("Comment added to #{issue.identifier}") + end) + end + + Display.show(one_or_many(Enum.map(pairs, &elem(&1, 1))), %{output: options.output}) :ok end end + defp add_comments_to_issues(issues, body) do + issues + |> Task.async_stream( + fn issue -> + case Linear.add_comment(issue.identifier, body) do + {:ok, comment} -> {:ok, {issue, comment}} + {:error, reason} -> {:error, reason} + end + end, + max_concurrency: min(length(issues), @max_concurrent_issue_updates), + ordered: true, + timeout: 30_000 + ) + |> Enum.reduce_while({:ok, []}, fn + {:ok, {:ok, pair}}, {:ok, acc} -> {:cont, {:ok, [pair | acc]}} + {:ok, {:error, reason}}, _acc -> {:halt, {:error, reason}} + {:exit, reason}, _acc -> {:halt, {:error, {:task_exit, reason}}} + end) + |> then(fn + {:ok, results} -> {:ok, Enum.reverse(results)} + error -> error + end) + end + defp validate_body_file_exclusion(options, text_key, flag_name) do if not is_nil(Map.get(options, :body_file)) and not is_nil(Map.get(options, text_key)) do {:error, {:smells_bad, "give #{flag_name} or --body-file, not both"}} diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index 51a3bb5..7bfcc74 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -3775,5 +3775,42 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert %{"id" => "c1"} = Jason.decode!(output) end + + test "multiple ISSUE_IDs each receive the comment" do + test_pid = self() + + stub_lookup_and([ + {"commentCreate", + fn _decoded -> + send(test_pid, :comment_created) + comment_created() + end} + ]) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "comment", "CRY-1", "CRY-2", "-m", "lgtm"]) + end) + + assert output =~ "Comment added to" + assert_received :comment_created + assert_received :comment_created + end + + test "no ISSUE_IDs is a smells_bad error" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) + + output = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "comment", "-m", "lgtm"], halt) + end) + + assert_received {:halted, 22} + assert output =~ "No issue IDs provided!" + end end end