From 469797dc91b4e494b0a4253b3faa7813ed91d32e Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Fri, 4 Sep 2026 23:19:56 -0400 Subject: [PATCH 1/2] feat(issue-update): add --body-file to lc issue update Read the description from a file (- for stdin) instead of passing it as a --description shell argument. Mutually exclusive with --description; --comment and --reason are unaffected. Wires the existing validate_body_file_exclusion/3 and resolve_body_from_file/2 helpers into issue_update/1, matching how issue create and issue comment already use them. Adds CLI tests: file read, stdin read, mutual-exclusion error, and unreadable-file error. Closes EXT-36 --- app/lib/linear_cli/cli.ex | 5 + app/lib/linear_cli/cli/commands.ex | 4 +- .../linear_cli/cli/issue_commands_test.exs | 123 ++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index b279244..616622c 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -878,6 +878,11 @@ defmodule LinearCli.CLI do long: "--description", help: "Update the issue description. - to open an editor" ], + body_file: [ + long: "--body-file", + help: + "Read the description from this file (- for stdin) instead of --description" + ], project: [ short: "-p", long: "--project", diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 579dae4..ecb6f00 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -538,11 +538,13 @@ defmodule LinearCli.CLI.Commands do @spec issue_update(Optimus.ParseResult.t()) :: :ok | {:error, term()} def issue_update(%{unknown: issue_ids, options: options, flags: flags}) do with :ok <- validate_issue_ids(issue_ids), + :ok <- validate_body_file_exclusion(options, :description, "--description"), + {:ok, description} <- resolve_body_from_file(options, :description), {:ok, issues} <- Linear.issues(%{ids: Enum.map(issue_ids, &IssueHelpers.expand_issue_id/1)}) do update_opts = [ comment: options.comment, - description: Map.get(options, :description), + description: description, project: options.project, cancel: flags.cancel, close: flags.close, diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index 7a98aeb..3c339aa 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -2097,6 +2097,129 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert output =~ "No issue IDs provided!" assert output =~ "This smells bad! Bailing." end + + test "--body-file reads the description from a file verbatim" do + path = tmp_path("body_file") + File.write!(path, "## Summary\n\nliteral \\n and $SOME_VAR survive verbatim") + on_exit(fn -> File.rm(path) end) + + 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, "issueUpdate") -> + send(test_pid, {:description, decoded["variables"]["input"]["description"]}) + Req.Test.json(conn, issue_updated(%{"description" => "## Summary\n\nliteral \\n and $SOME_VAR survive verbatim"})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "update", + "--body-file", + path, + "CRY-1" + ]) + end) + + assert_received {:description, "## Summary\n\nliteral \\n and $SOME_VAR survive verbatim"} + assert output =~ "CRY-1 description updated" + end + + test "--body-file - reads the description from stdin" 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, "issueUpdate") -> + send(test_pid, {:description, decoded["variables"]["input"]["description"]}) + Req.Test.json(conn, issue_updated(%{"description" => "from stdin body"})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + result = %{ + unknown: ["CRY-1"], + options: %{body_file: "-", description: nil, comment: nil, project: nil, reason: nil, status: nil}, + flags: %{cancel: false, close: false, trash: false} + } + + capture_io("from stdin body", fn -> + assert :ok = Commands.issue_update(result) + end) + + assert_received {:description, "from stdin body"} + end + + test "--description and --body-file together is a smells_bad error, no GraphQL call" 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", + "update", + "-d", + "some desc", + "--body-file", + "somefile", + "CRY-1" + ], + halt + ) + end) + + assert_received {:halted, 22} + assert output =~ "give --description or --body-file, not both" + end + + test "an unreadable --body-file surfaces an error, no GraphQL call" 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) + + capture_io(:stderr, fn -> + LinearCli.CLI.main( + [ + "issue", + "update", + "--body-file", + "/nonexistent/path/does-not-exist", + "CRY-1" + ], + halt + ) + end) + + assert_received {:halted, _code} + end end describe "issue assign" do From d572f83b34210772a884dedf4f712c5a5fd90f7a Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Fri, 4 Sep 2026 23:20:59 -0400 Subject: [PATCH 2/2] style(test): reformat issue update --body-file tests to pass mix format Co-Authored-By: Claude Sonnet 4.6 --- app/test/linear_cli/cli/issue_commands_test.exs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index 3c339aa..13aedf8 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -2116,7 +2116,13 @@ defmodule LinearCli.CLI.IssueCommandsTest do String.contains?(query, "issueUpdate") -> send(test_pid, {:description, decoded["variables"]["input"]["description"]}) - Req.Test.json(conn, issue_updated(%{"description" => "## Summary\n\nliteral \\n and $SOME_VAR survive verbatim"})) + + Req.Test.json( + conn, + issue_updated(%{ + "description" => "## Summary\n\nliteral \\n and $SOME_VAR survive verbatim" + }) + ) true -> raise "no stub matched query: #{query}" @@ -2162,7 +2168,14 @@ defmodule LinearCli.CLI.IssueCommandsTest do result = %{ unknown: ["CRY-1"], - options: %{body_file: "-", description: nil, comment: nil, project: nil, reason: nil, status: nil}, + options: %{ + body_file: "-", + description: nil, + comment: nil, + project: nil, + reason: nil, + status: nil + }, flags: %{cancel: false, close: false, trash: false} }