From a41dce0b9fda36eda9b2b9d127a75ff3e3f8e1c5 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Tue, 1 Sep 2026 10:30:32 -0400 Subject: [PATCH] feat: add --body-file to lc issue create for --description Mirrors Phase 13's `lc issue comment --body-file` pattern: - `--body-file PATH` reads the description verbatim from a file - `--body-file -` reads from stdin via IO.read(:stdio, :eof) - `--description` and `--body-file` together produce a smells_bad error Generalises `validate_comment_options/1` and `resolve_comment_body/1` into `validate_body_file_exclusion/3` and `resolve_body_from_file/2` so both issue comment and issue create share the same body-file logic. Closes EXT-21. Co-Authored-By: Claude Sonnet 4.6 --- app/lib/linear_cli/cli.ex | 5 + app/lib/linear_cli/cli/commands.ex | 44 +++-- .../linear_cli/cli/issue_commands_test.exs | 182 ++++++++++++++++++ 3 files changed, 212 insertions(+), 19 deletions(-) diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 756683b..6082dd4 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -710,6 +710,11 @@ defmodule LinearCli.CLI do "for the required information.", options: [ description: [short: "-d", long: "--description", help: "Issue Description"], + body_file: [ + long: "--body-file", + help: + "Read the description from this file (- for stdin) instead of --description" + ], labels: [ short: "-l", long: "--labels", diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 8bf1917..6fe1ea8 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -326,15 +326,16 @@ defmodule LinearCli.CLI.Commands do def issue_create(result, opts \\ []) def issue_create(%{options: options, flags: flags}, opts) do - create_opts = [ - title: options.title, - description: options.description, - team: options.team, - labels: options.labels, - project: options.project - ] - - with {:ok, issue} <- IssueHelpers.make_da_issue!(create_opts), + with :ok <- validate_body_file_exclusion(options, :description, "--description"), + {:ok, description} <- resolve_body_from_file(options, :description), + create_opts = [ + title: options.title, + description: description, + team: options.team, + labels: options.labels, + project: options.project + ], + {:ok, issue} <- IssueHelpers.make_da_issue!(create_opts), :ok <- maybe_take(issue, opts) do Display.show(issue, %{output: options.output}) if flags.develop, do: run_develop(issue.id, opts), else: :ok @@ -539,8 +540,8 @@ defmodule LinearCli.CLI.Commands do """ @spec issue_comment(Optimus.ParseResult.t()) :: :ok | {:error, term()} def issue_comment(%{args: %{issue_id: issue_id}, options: options}) do - with :ok <- validate_comment_options(options), - {:ok, comment_text} <- resolve_comment_body(options), + with :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 @@ -550,16 +551,21 @@ defmodule LinearCli.CLI.Commands do end end - defp validate_comment_options(%{comment: comment, body_file: body_file}) - when not is_nil(comment) and not is_nil(body_file) do - {:error, {:smells_bad, "give --comment or --body-file, not both"}} + 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"}} + else + :ok + end end - defp validate_comment_options(_options), do: :ok - - defp resolve_comment_body(%{body_file: nil, comment: comment}), do: {:ok, comment} - defp resolve_comment_body(%{body_file: "-"}), do: {:ok, read_stdin()} - defp resolve_comment_body(%{body_file: path}), do: File.read(path) + defp resolve_body_from_file(options, text_key) do + case Map.get(options, :body_file) do + nil -> {:ok, Map.get(options, text_key)} + "-" -> {:ok, read_stdin()} + path -> File.read(path) + end + end defp read_stdin do case IO.read(:stdio, :eof) do diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index ff0f087..9585016 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -603,6 +603,188 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert output =~ "Set upstream to origin/cry-2-new-thing" assert output =~ "Ready to develop!" end + + test "--body-file reads the description from a file verbatim" do + path = tmp_path("body_file") + # Includes a literal backslash-n and a $VAR-looking string — the same + # content that broke when built as an inline shell argument (EXT-17 incident). + 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, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "issueLabels") -> + Req.Test.json(conn, label_response(["docs"])) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, team_projects([])) + + String.contains?(query, "issueCreate") -> + send(test_pid, {:sent_description, decoded["variables"]["input"]["description"]}) + + Req.Test.json(conn, %{ + "data" => %{ + "issueCreate" => %{ + "issue" => issue_map(%{"identifier" => "CRY-2", "title" => "T"}) + } + } + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io([input: "n\n"], fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "create", + "--body-file", + path, + "--title", + "T", + "--team", + "ENG", + "-l", + "docs" + ]) + end) + + assert_received {:sent_description, + "## Summary\n\nliteral \\n and $SOME_VAR survive verbatim"} + end + + test "--body-file - reads the description from stdin" do + # Uses Commands.issue_create directly so that IO.read(:stdio, :eof) only + # consumes the piped content (not the yes/no prompt input too). The + # maybe_take prompt gets EOF after stdin is consumed; Owl.IO.confirm with + # default: true returns true, so gimme_da_issue! runs and finds the issue + # already assigned to `me`, short-circuiting without a second mutation. + test_pid = self() + me = %User{id: "u1", name: "Ada", email: "ada@x.com"} + + 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, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "issueLabels") -> + Req.Test.json(conn, label_response([])) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, team_projects([])) + + String.contains?(query, "issueCreate") -> + send(test_pid, {:sent_description, decoded["variables"]["input"]["description"]}) + + Req.Test.json(conn, %{ + "data" => %{ + "issueCreate" => %{ + "issue" => issue_map(%{"id" => "i2", "identifier" => "CRY-2", "title" => "T"}) + } + } + }) + + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{ + "data" => %{ + "issue" => + issue_map(%{"id" => "i2", "identifier" => "CRY-2", "assignee" => me_map()}) + } + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + result = %{ + options: %{ + title: "T", + body_file: "-", + description: nil, + team: "ENG", + labels: [], + project: nil, + output: "text" + }, + flags: %{develop: false} + } + + capture_io("piped from stdin\nwith a real newline", fn -> + assert :ok = Commands.issue_create(result, me: me) + end) + + assert_received {:sent_description, "piped from stdin\nwith a real newline"} + 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", + "create", + "--title", + "T", + "--team", + "ENG", + "-d", + "some desc", + "--body-file", + "somefile" + ], + 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", + "create", + "--body-file", + "/nonexistent/path/does-not-exist", + "--title", + "T", + "--team", + "ENG" + ], + halt + ) + end) + + assert_received {:halted, _code} + end end describe "issue develop (Ruby: commands/issue/develop.rb)" do