Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions app/lib/linear_cli/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,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",
Expand Down
44 changes: 25 additions & 19 deletions app/lib/linear_cli/cli/commands.ex
Original file line number Diff line number Diff line change
Expand Up @@ -341,15 +341,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
Expand Down Expand Up @@ -554,8 +555,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
Expand All @@ -565,16 +566,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
Expand Down
182 changes: 182 additions & 0 deletions app/test/linear_cli/cli/issue_commands_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,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
Expand Down
Loading