diff --git a/Readme.adoc b/Readme.adoc index 342ffdd..8c91788 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -465,8 +465,11 @@ $ lc issue move --from Retired --to Active --dry-run <4> <4> Preview without mutating NOTE: In JSON mode, ID and bulk moves require `--yes` or `--dry-run`. Project -names must match exactly. A name-based bulk move must provide `--team` or an -active profile; UUID-only moves do not need either. Partial matches and other +names must match exactly. A bare-number ID must resolve its team from an +active profile, one favorite team, or the only available team. If team +selection is ambiguous, the command writes a diagnostic to stderr and exits +before mutation. A name-based bulk move must provide `--team` or an active +profile; UUID-only moves do not need either. Partial matches and other prompt-required inputs return a diagnostic on stderr. A successful dry-run writes the selected issues as one JSON value. diff --git a/app/lib/linear_cli/cli/commands/issues/development.ex b/app/lib/linear_cli/cli/commands/issues/development.ex index 0468619..6aa1746 100644 --- a/app/lib/linear_cli/cli/commands/issues/development.ex +++ b/app/lib/linear_cli/cli/commands/issues/development.ex @@ -6,7 +6,7 @@ defmodule LinearCli.CLI.Commands.Issues.Development do """ alias LinearCli.CLI.{Display, Prompt} - alias LinearCli.CLI.Issue.{Assignment, PullRequest} + alias LinearCli.CLI.Issue.{Assignment, Identifiers, PullRequest} alias LinearCli.Git @doc """ @@ -25,7 +25,9 @@ defmodule LinearCli.CLI.Commands.Issues.Development do """ @spec issue_develop(Optimus.ParseResult.t(), keyword()) :: :ok | {:error, term()} def issue_develop(result, opts \\ []) - def issue_develop(%{args: %{issue_id: issue_id}}, opts), do: run_develop(issue_id, opts) + + def issue_develop(%{args: %{issue_id: issue_id}} = result, opts), + do: run_develop(issue_id, maybe_put(opts, :output, result_output(result))) @doc """ Ported from commands/issue/pr.rb: resolves/self-assigns `issue_id`, checks @@ -42,6 +44,8 @@ defmodule LinearCli.CLI.Commands.Issues.Development do def issue_pr(result, opts \\ []) def issue_pr(%{args: %{issue_id: issue_id}, options: options}, opts) do + opts = maybe_put(opts, :output, Map.get(options, :output, "text")) + with {:ok, issue} <- Assignment.gimme_da_issue!(issue_id, opts), {:ok, _branch} <- Git.checkout_branch(issue.branch_name, opts) do Prompt.ok("Checked out branch #{issue.branch_name}") @@ -72,10 +76,13 @@ defmodule LinearCli.CLI.Commands.Issues.Development do def issue_take(result, opts \\ []) def issue_take(%{unknown: issue_ids, options: options}, opts) do - opts = maybe_put_status(opts, Map.get(options, :status)) + opts = + opts + |> maybe_put_status(Map.get(options, :status)) + |> maybe_put(:output, Map.get(options, :output, "text")) with {:ok, updates} <- take_issues(issue_ids, opts) do - Display.show(updates, %{output: options.output}) + Display.show(updates, %{output: Map.get(options, :output, "text")}) :ok end end @@ -112,27 +119,40 @@ defmodule LinearCli.CLI.Commands.Issues.Development do defp maybe_put(list, _key, nil), do: list defp maybe_put(list, key, value), do: Keyword.put(list, key, value) + defp result_output(%{options: options}), do: Map.get(options, :output, "text") + defp result_output(_result), do: "text" + defp maybe_put_status(opts, nil), do: opts defp maybe_put_status(opts, status), do: Keyword.put(opts, :status, status) defp take_issues(issue_ids, opts) do - issue_ids - |> Enum.reduce_while({:ok, []}, fn issue_id, {:ok, acc} -> - case Assignment.gimme_da_issue!(issue_id, opts) do - {:ok, issue} -> - {:cont, {:ok, [issue | acc]}} - - {:error, %Ash.Error.Unknown{errors: [%{value: [{:not_found, id}]} | _]}} -> - Prompt.warn("No issue found with id #{id}") - {:cont, {:ok, acc}} - - {:error, reason} -> - {:halt, {:error, reason}} + with {:ok, resolved_ids} <- preflight_take_ids(issue_ids, opts) do + resolved_ids + |> Enum.reduce_while({:ok, []}, fn issue_id, {:ok, acc} -> + case Assignment.gimme_da_issue!(issue_id, opts) do + {:ok, issue} -> + {:cont, {:ok, [issue | acc]}} + + {:error, %Ash.Error.Unknown{errors: [%{value: [{:not_found, id}]} | _]}} -> + Prompt.warn("No issue found with id #{id}") + {:cont, {:ok, acc}} + + {:error, reason} -> + {:halt, {:error, reason}} + end + end) + |> case do + {:ok, acc} -> {:ok, Enum.reverse(acc)} + error -> error end - end) - |> case do - {:ok, acc} -> {:ok, Enum.reverse(acc)} - error -> error + end + end + + defp preflight_take_ids(issue_ids, opts) do + if Keyword.get(opts, :output, "text") == "json" do + Identifiers.expand_issue_ids(issue_ids, output: "json") + else + {:ok, issue_ids} end end end diff --git a/app/lib/linear_cli/cli/commands/issues/move.ex b/app/lib/linear_cli/cli/commands/issues/move.ex index 4ecb819..a34abe3 100644 --- a/app/lib/linear_cli/cli/commands/issues/move.ex +++ b/app/lib/linear_cli/cli/commands/issues/move.ex @@ -41,8 +41,9 @@ defmodule LinearCli.CLI.Commands.Issues.Move do defp move_issues_by_id(issue_ids, options, flags) do with :ok <- validate_issue_ids(issue_ids), + {:ok, expanded_ids} <- Identifiers.expand_issue_ids(issue_ids, output: options.output), {:ok, issues} <- - Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}), + Linear.issues(%{ids: expanded_ids}), {:ok, project} <- resolve_move_project(issues, options) do print_move_plan(issues, project, options.output) execute_moves_if_confirmed(issues, project, flags, options.output) diff --git a/app/lib/linear_cli/cli/commands/issues/mutations.ex b/app/lib/linear_cli/cli/commands/issues/mutations.ex index 535f47b..980dd9b 100644 --- a/app/lib/linear_cli/cli/commands/issues/mutations.ex +++ b/app/lib/linear_cli/cli/commands/issues/mutations.ex @@ -31,12 +31,15 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do """ @spec issue_update(Optimus.ParseResult.t()) :: :ok | {:error, term()} def issue_update(%{unknown: issue_ids, options: options, flags: flags}) do + output = Map.get(options, :output, "text") + with :ok <- validate_issue_ids(issue_ids), :ok <- validate_body_file_exclusion(options, :description, "--description"), {:ok, description} <- resolve_body_from_file(options, :description), {:ok, priority} <- parse_priority(Map.get(options, :priority)), + {:ok, expanded_ids} <- Identifiers.expand_issue_ids(issue_ids, output: output), {:ok, issues} <- - Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}) do + Linear.issues(%{ids: expanded_ids}) do update_opts = [ comment: options.comment, description: description, @@ -82,20 +85,23 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do """ @spec issue_comment(Optimus.ParseResult.t()) :: :ok | {:error, term()} def issue_comment(%{unknown: issue_ids, options: options}) do + output = Map.get(options, :output, "text") + 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, expanded_ids} <- Identifiers.expand_issue_ids(issue_ids, output: output), {:ok, issues} <- - Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}), + Linear.issues(%{ids: expanded_ids}), body = WhatFor.comment_for(hd(issues), comment_text), {:ok, pairs} <- add_comments_to_issues(issues, body) do - unless options.output == "json" do + unless 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}) + Display.show(one_or_many(Enum.map(pairs, &elem(&1, 1))), %{output: output}) :ok end end @@ -113,12 +119,15 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do """ @spec issue_status(Optimus.ParseResult.t()) :: :ok | {:error, term()} def issue_status(%{unknown: issue_ids, options: options}) do + output = Map.get(options, :output, "text") + with :ok <- validate_issue_ids(issue_ids), + {:ok, expanded_ids} <- Identifiers.expand_issue_ids(issue_ids, output: output), {:ok, issues} <- - Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}), + Linear.issues(%{ids: expanded_ids}), {:ok, planned_updates} <- plan_status_updates(issues, options.status), {:ok, completed_updates} <- apply_status_updates(planned_updates, options.comment) do - show_status_updates(completed_updates, options.output) + show_status_updates(completed_updates, output) end end @@ -150,17 +159,18 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do """ @spec issue_assign(Optimus.ParseResult.t()) :: :ok | {:error, term()} def issue_assign(%{args: %{issue_id: issue_id}, options: options}) do - expanded_id = Identifiers.expand_issue_id(issue_id) + output = Map.get(options, :output, "text") - with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}), + with {:ok, expanded_id} <- Identifiers.expand_issue_id(issue_id, output: output), + {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}), {:ok, members} <- Linear.team_members(issue.team.id), :ok <- guard_has_members(members, issue), {:ok, target_member} <- resolve_target_member(members, options.assignee), {:ok, state_id} <- resolve_optional_status(issue, Map.get(options, :status)), {:ok, updated} <- Linear.assign_issue(issue, target_member.id, %{state_id: state_id}) do - Display.show(updated, %{output: options.output}) + Display.show(updated, %{output: output}) - if options.output != "json" do + if output != "json" do msg = "#{updated.identifier} assigned to #{target_member.name}" msg = @@ -176,8 +186,10 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do end defp issue_unassign_by_ids(issue_ids, options) do - with {:ok, issues} <- - Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}), + output = Map.get(options, :output, "text") + + with {:ok, expanded_ids} <- Identifiers.expand_issue_ids(issue_ids, output: output), + {:ok, issues} <- Linear.issues(%{ids: expanded_ids}), {:ok, updated_issues} <- unassign_issues(issues) do show_unassign_results(updated_issues, options) end diff --git a/app/lib/linear_cli/cli/commands/issues/read.ex b/app/lib/linear_cli/cli/commands/issues/read.ex index e0f69c9..89c0c08 100644 --- a/app/lib/linear_cli/cli/commands/issues/read.ex +++ b/app/lib/linear_cli/cli/commands/issues/read.ex @@ -26,8 +26,8 @@ defmodule LinearCli.CLI.Commands.Issues.Read do `--team`/`--project` passed explicitly always win over the active profile. """ def issue_list(%{flags: flags, options: options, unknown: ids}) do - with {:ok, input} <- - Filter.build_input(flags, options, Enum.map(ids, &Identifiers.expand_issue_id/1)) do + with {:ok, expanded_ids} <- Identifiers.expand_issue_ids(ids, output: options.output), + {:ok, input} <- Filter.build_input(flags, options, expanded_ids) do with {:ok, issues} <- Linear.issues(input) do Display.show(issues, %{ output: options.output, @@ -61,18 +61,16 @@ defmodule LinearCli.CLI.Commands.Issues.Read do {:error, {:smells_bad, "--graph and --web cannot be used together"}} graph? -> - expanded_id = Identifiers.expand_issue_id(issue_id) - - with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}), + with {:ok, expanded_id} <- Identifiers.expand_issue_id(issue_id, output: options.output), + {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}), {:ok, graph} <- Graph.build(issue.identifier, issue) do Display.show_graph(graph, %{output: options.output}) :ok end true -> - expanded_id = Identifiers.expand_issue_id(issue_id) - - with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}) do + with {:ok, expanded_id} <- Identifiers.expand_issue_id(issue_id, output: options.output), + {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}) do if web? do Browser.open_url(issue.url, opts) else diff --git a/app/lib/linear_cli/cli/commands/issues/relations.ex b/app/lib/linear_cli/cli/commands/issues/relations.ex index 45b498c..491d472 100644 --- a/app/lib/linear_cli/cli/commands/issues/relations.ex +++ b/app/lib/linear_cli/cli/commands/issues/relations.ex @@ -17,9 +17,8 @@ defmodule LinearCli.CLI.Commands.Issues.Relations do """ @spec issue_relation_list(Optimus.ParseResult.t()) :: :ok | {:error, term()} def issue_relation_list(%{args: %{issue_id: issue_id}, options: options}) do - expanded_id = Identifiers.expand_issue_id(issue_id) - - with {:ok, relations} <- Linear.issue_relations(expanded_id) do + with {:ok, expanded_id} <- Identifiers.expand_issue_id(issue_id, output: options.output), + {:ok, relations} <- Linear.issue_relations(expanded_id) do Display.show(relations, %{output: options.output, relations: true}) :ok end @@ -50,16 +49,33 @@ defmodule LinearCli.CLI.Commands.Issues.Relations do do: {:error, {:smells_bad, "At least one RELATED_ISSUE is required"}} def issue_relation_add(%{unknown: [subject_id | related_ids], options: options}, opts) do - expanded_subject = Identifiers.expand_issue_id(subject_id) user_type = options.type - results = - Enum.map(related_ids, fn related_id -> - expanded_related = Identifiers.expand_issue_id(related_id) - add_single_relation(expanded_subject, expanded_related, user_type) - end) + if options.output == "json" do + with {:ok, [expanded_subject | expanded_related_ids]} <- + Identifiers.expand_issue_ids([subject_id | related_ids], output: options.output) do + results = + Enum.map(expanded_related_ids, fn expanded_related -> + add_single_relation(expanded_subject, expanded_related, user_type) + end) + + finish_relation_add(results, options.output, Keyword.get(opts, :stderr, :stderr)) + end + else + expanded_subject = Identifiers.expand_issue_id(subject_id) + + results = + Enum.map(related_ids, fn related_id -> + expanded_related = Identifiers.expand_issue_id(related_id) + add_single_relation(expanded_subject, expanded_related, user_type) + end) + + finish_relation_add(results, options.output, Keyword.get(opts, :stderr, :stderr)) + end + end - print_relation_add_results(results, options.output, Keyword.get(opts, :stderr, :stderr)) + defp finish_relation_add(results, output, stderr) do + print_relation_add_results(results, output, stderr) failed_count = Enum.count(results, fn r -> match?({:failed, _, _}, r) or match?({:self_link, _}, r) end) @@ -205,32 +221,50 @@ defmodule LinearCli.CLI.Commands.Issues.Relations do do: {:error, {:smells_bad, "At least one RELATED_ISSUE is required"}} def issue_relation_remove(%{unknown: [subject_id | related_ids], options: options}, opts) do - expanded_subject = Identifiers.expand_issue_id(subject_id) user_type = options.type - with {:ok, all_relations} <- Linear.issue_relations(expanded_subject) do - results = - Enum.map(related_ids, fn related_id -> - expanded_related = Identifiers.expand_issue_id(related_id) - remove_single_relation(expanded_subject, expanded_related, user_type, all_relations) - end) + if options.output == "json" do + with {:ok, [expanded_subject | expanded_related_ids]} <- + Identifiers.expand_issue_ids([subject_id | related_ids], output: options.output), + {:ok, all_relations} <- Linear.issue_relations(expanded_subject) do + results = + Enum.map(expanded_related_ids, fn expanded_related -> + remove_single_relation(expanded_subject, expanded_related, user_type, all_relations) + end) - print_relation_remove_results(results, options.output, Keyword.get(opts, :stderr, :stderr)) + finish_relation_remove(results, options.output, Keyword.get(opts, :stderr, :stderr)) + end + else + expanded_subject = Identifiers.expand_issue_id(subject_id) - failed_count = - Enum.count(results, fn r -> - match?({:failed, _, _}, r) or match?({:ambiguous, _, _}, r) or - match?({:self_link, _}, r) - end) + with {:ok, all_relations} <- Linear.issue_relations(expanded_subject) do + results = + Enum.map(related_ids, fn related_id -> + expanded_related = Identifiers.expand_issue_id(related_id) + remove_single_relation(expanded_subject, expanded_related, user_type, all_relations) + end) - if failed_count > 0 do - {:error, {:smells_bad, "#{failed_count} relation(s) failed to be removed"}} - else - :ok + finish_relation_remove(results, options.output, Keyword.get(opts, :stderr, :stderr)) end end end + defp finish_relation_remove(results, output, stderr) do + print_relation_remove_results(results, output, stderr) + + failed_count = + Enum.count(results, fn r -> + match?({:failed, _, _}, r) or match?({:ambiguous, _, _}, r) or + match?({:self_link, _}, r) + end) + + if failed_count > 0 do + {:error, {:smells_bad, "#{failed_count} relation(s) failed to be removed"}} + else + :ok + end + end + defp remove_single_relation(subject_id, related_id, _user_type, _relations) when subject_id == related_id do {:self_link, subject_id} diff --git a/app/lib/linear_cli/cli/issue/assignment.ex b/app/lib/linear_cli/cli/issue/assignment.ex index 07b3be7..2fd29e8 100644 --- a/app/lib/linear_cli/cli/issue/assignment.ex +++ b/app/lib/linear_cli/cli/issue/assignment.ex @@ -41,10 +41,11 @@ defmodule LinearCli.CLI.Issue.Assignment do """ @spec gimme_da_issue!(String.t(), keyword()) :: {:ok, %Linear.Issue{}} | {:error, term()} def gimme_da_issue!(issue_id, opts \\ []) do - issue_id = Identifiers.expand_issue_id(issue_id) status_opt = parse_status_opt(opts) - with {:ok, me} <- resolve_me(opts), + with {:ok, issue_id} <- + Identifiers.expand_issue_id(issue_id, output: Keyword.get(opts, :output, "text")), + {:ok, me} <- resolve_me(opts), {:ok, [issue]} <- Linear.issues(%{ids: [issue_id]}), {:ok, state_id} <- resolve_status_for_issue(issue, status_opt) do assign_or_confirm(issue, me, issue_id, state_id) diff --git a/app/lib/linear_cli/cli/issue/identifiers.ex b/app/lib/linear_cli/cli/issue/identifiers.ex index 7669929..566fe25 100644 --- a/app/lib/linear_cli/cli/issue/identifiers.ex +++ b/app/lib/linear_cli/cli/issue/identifiers.ex @@ -8,15 +8,16 @@ defmodule LinearCli.CLI.Issue.Identifiers do identifier from the user so that bare numbers work wherever full identifiers do. - Team resolution order (never a hard error short of the user having no teams - at all): the active profile's team (`LinearCli.Profiles.default_team/0`) -> - favorited teams (`LinearCli.Favorites.list/1`, single favorite used directly, - several prompted) -> a prompt across every team the user belongs to - (`LinearCli.CLI.WhatFor.ask_for_team/0`). + Text-mode team resolution uses the active profile's team + (`LinearCli.Profiles.default_team/0`), then favorited teams + (`LinearCli.Favorites.list/1`), then a prompt across every team the user + belongs to (`LinearCli.CLI.WhatFor.ask_for_team/0`). JSON mode uses the same + deterministic sources, but returns an error instead of prompting when a + choice is required. """ alias LinearCli.CLI.{Prompt, WhatFor} - alias LinearCli.{Favorites, Profiles} + alias LinearCli.{Favorites, Linear, Profiles} # A "bare" issue id is just digits - anything with a `-` (an already # team-prefixed identifier, e.g. "CRY-1234") or that otherwise doesn't @@ -27,36 +28,90 @@ defmodule LinearCli.CLI.Issue.Identifiers do @doc """ Expands a bare issue number (`~r/^\\d+$/`, e.g. `"1234"`) to a full team-prefixed identifier (`"CRY-1234"`) by resolving a team key via - `resolve_bare_team/0`. Anything else (an already-prefixed identifier, a + `resolve_bare_team/1`. Anything else (an already-prefixed identifier, a UUID) is returned unchanged. - Team resolution order, never a hard error short of the user having no - teams at all: the active profile's team (`LinearCli.Profiles.default_team/0`) - -> favorited teams (`LinearCli.Favorites.list/1`, single favorite used - directly, several prompted) -> a prompt across every team the user - belongs to (`LinearCli.CLI.WhatFor.ask_for_team/0`). + The one-argument form keeps text-mode behavior. The output-aware form returns + `{:ok, identifier}` or `{:error, reason}` and rejects team prompts in JSON + mode. + + Text mode resolves teams from the active profile, one favorite, or a prompt + across the user's teams. JSON mode accepts only deterministic resolution + and returns an error when a team choice would require a prompt. """ @spec expand_issue_id(String.t()) :: String.t() def expand_issue_id(issue_id) do + case expand_issue_id(issue_id, output: "text") do + {:ok, expanded_id} -> expanded_id + {:error, reason} -> raise "Could not expand issue id #{issue_id}: #{inspect(reason)}" + end + end + + @spec expand_issue_id(String.t(), keyword()) :: {:ok, String.t()} | {:error, term()} + def expand_issue_id(issue_id, opts) do if Regex.match?(@bare_issue_id_regex, issue_id) do - "#{resolve_bare_team()}-#{issue_id}" + with {:ok, team_key} <- resolve_bare_team(opts) do + {:ok, "#{team_key}-#{issue_id}"} + end else - issue_id + {:ok, issue_id} end end - defp resolve_bare_team do + @spec expand_issue_ids([String.t()], keyword()) :: + {:ok, [String.t()]} | {:error, term()} + def expand_issue_ids(issue_ids, opts) do + Enum.reduce_while(issue_ids, {:ok, []}, fn issue_id, {:ok, expanded_ids} -> + case expand_issue_id(issue_id, opts) do + {:ok, expanded_id} -> {:cont, {:ok, [expanded_id | expanded_ids]}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + {:ok, expanded_ids} -> {:ok, Enum.reverse(expanded_ids)} + error -> error + end + end + + defp resolve_bare_team(opts) do case Profiles.default_team() do - nil -> resolve_bare_team_from_favorites() - team_key -> team_key + nil -> resolve_bare_team_from_favorites(opts) + team_key -> {:ok, team_key} end end - defp resolve_bare_team_from_favorites do + defp resolve_bare_team_from_favorites(opts) do case Favorites.list("team") do - [] -> WhatFor.ask_for_team().key - [team_key] -> team_key - team_keys -> Prompt.select("Choose a team", Enum.map(team_keys, &{&1, &1})) + [] -> + resolve_bare_team_from_available_teams(opts) + + [team_key] -> + {:ok, team_key} + + team_keys -> + if Keyword.get(opts, :output, "text") == "json" do + {:error, ambiguous_team_error()} + else + {:ok, Prompt.select("Choose a team", Enum.map(team_keys, &{&1, &1}))} + end end end + + defp resolve_bare_team_from_available_teams(opts) do + if Keyword.get(opts, :output, "text") == "json" do + case Linear.my_teams() do + {:ok, [team]} -> {:ok, team.key} + {:ok, _teams} -> {:error, ambiguous_team_error()} + {:error, reason} -> {:error, reason} + end + else + {:ok, WhatFor.ask_for_team().key} + end + end + + defp ambiguous_team_error do + {:smells_bad, + "JSON output cannot prompt for a team while expanding a bare issue ID. " <> + "Use a full team-prefixed ID, an active profile, or one favorite team."} + end end diff --git a/app/test/linear_cli/cli/commands/issues/json_bare_issue_id_test.exs b/app/test/linear_cli/cli/commands/issues/json_bare_issue_id_test.exs new file mode 100644 index 0000000..505495b --- /dev/null +++ b/app/test/linear_cli/cli/commands/issues/json_bare_issue_id_test.exs @@ -0,0 +1,160 @@ +defmodule LinearCli.CLI.Commands.Issues.JsonBareIssueIdTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureIO + import LinearCli.CLI.IssueCommandsHelpers + + alias LinearCli.CLI.Issue.Identifiers + alias LinearCli.{Favorites, Profiles} + + setup do + path = Application.fetch_env!(:linear_cli, :profiles_db_path) + File.rm(path) + + on_exit(fn -> File.rm(path) end) + :ok + end + + defp teams_response(teams) do + %{ + "data" => %{ + "viewer" => %{ + "id" => "u1", + "name" => "Ada", + "email" => "ada@example.com", + "teams" => %{"nodes" => teams} + } + } + } + end + + defp team_map(key, id) do + %{"id" => id, "key" => key, "name" => key} + end + + defp run_cli(argv, halt) do + parent = self() + + stdout = + capture_io(fn -> + stderr = + capture_stderr(fn stderr -> + LinearCli.CLI.main(argv, halt, stderr: stderr) + end) + + send(parent, {:captured_stderr, stderr}) + end) + + receive do + {:captured_stderr, stderr} -> {stdout, stderr} + after + 1_000 -> raise "did not receive captured stderr" + end + end + + test "issue move rejects an ambiguous favorite-team choice before any API call" do + test_pid = self() + Favorites.add("team", "ENG") + Favorites.add("team", "SUP") + + Req.Test.stub(LinearCli.Api, fn conn -> + send(test_pid, :api_called) + Req.Test.json(conn, %{"data" => %{}}) + end) + + halt = fn code -> send(test_pid, {:halted, code}) end + + {stdout, stderr} = + run_cli( + ["issue", "move", "--project", "Manhattan", "--yes", "--output", "json", "42"], + halt + ) + + assert stdout == "" + assert stderr =~ "JSON output cannot prompt for a team" + assert_received {:halted, 22} + refute_received :api_called + end + + test "issue unassign rejects an ambiguous available-team choice before any API call" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + if String.contains?(query, "viewer") do + Req.Test.json(conn, teams_response([team_map("ENG", "t1"), team_map("SUP", "t2")])) + else + send(test_pid, :api_called) + Req.Test.json(conn, %{"data" => %{}}) + end + end) + + halt = fn code -> send(test_pid, {:halted, code}) end + {stdout, stderr} = run_cli(["issue", "unassign", "--output", "json", "42"], halt) + + assert stdout == "" + assert stderr =~ "JSON output cannot prompt for a team" + assert_received {:halted, 22} + refute_received :api_called + end + + test "issue move uses one favorite team in JSON mode without prompting" do + test_pid = self() + Favorites.add("team", "ENG") + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = decoded = Jason.decode!(body) + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json( + conn, + %{ + "data" => %{ + "issue" => + issue_map(%{ + "identifier" => "ENG-42", + "team" => team_map("ENG", "t1") + }) + } + } + ) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, team_projects([project_map("p1", "Manhattan")])) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:mutation, decoded["variables"]["input"]["projectId"]}) + Req.Test.json(conn, issue_updated(%{"identifier" => "ENG-42"})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + {stdout, stderr} = + run_cli( + ["issue", "move", "--project", "Manhattan", "--yes", "--output", "json", "42"], + fn _code -> + flunk("one favorite team should not halt") + end + ) + + assert {:ok, decoded} = Jason.decode(stdout) + assert decoded["identifier"] == "ENG-42" + assert stderr == "" + assert_received {:mutation, "p1"} + end + + test "active profile resolution also works in JSON mode without output" do + {:ok, _profile} = Profiles.create("engineering", team: "ENG") + :ok = Profiles.activate("engineering") + + assert capture_io(fn -> + assert {:ok, "ENG-42"} = Identifiers.expand_issue_id("42", output: "json") + end) == "" + end +end diff --git a/app/test/linear_cli/cli/expand_issue_id_test.exs b/app/test/linear_cli/cli/expand_issue_id_test.exs index 14a9d62..401b6cb 100644 --- a/app/test/linear_cli/cli/expand_issue_id_test.exs +++ b/app/test/linear_cli/cli/expand_issue_id_test.exs @@ -76,6 +76,59 @@ defmodule LinearCli.CLI.ExpandIssueIdTest do assert output =~ "Choose a team" end + test "JSON mode rejects several favorited teams without prompting" do + Favorites.add("team", "ENG") + Favorites.add("team", "SUP") + + output = + capture_io(fn -> + assert {:error, {:smells_bad, message}} = + Identifiers.expand_issue_id("42", output: "json") + + assert message =~ "cannot prompt for a team" + end) + + assert output == "" + end + + test "JSON mode rejects several available teams without prompting" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json( + conn, + teams_response([ + %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, + %{"id" => "t2", "key" => "SUP", "name" => "Support"} + ]) + ) + end) + + output = + capture_io(fn -> + assert {:error, {:smells_bad, message}} = + Identifiers.expand_issue_id("42", output: "json") + + assert message =~ "cannot prompt for a team" + end) + + assert output == "" + end + + test "JSON mode uses the only available team without prompting" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json( + conn, + teams_response([%{"id" => "t1", "key" => "ENG", "name" => "Engineering"}]) + ) + end) + + output = + capture_io(fn -> + assert {:ok, "ENG-42"} = Identifiers.expand_issue_id("42", output: "json") + end) + + assert output == "" + end + test "an already-prefixed id and a UUID pass through unchanged, regardless of profile/favorites" do {:ok, _} = Profiles.create("manhattan", team: "CRY") :ok = Profiles.activate("manhattan") @@ -88,5 +141,12 @@ defmodule LinearCli.CLI.ExpandIssueIdTest do "550e8400-e29b-41d4-a716-446655440000" end) == "" end + + test "output-aware expansion keeps full and UUID identifiers unchanged" do + assert {:ok, "CRY-1234"} = Identifiers.expand_issue_id("CRY-1234", output: "json") + + uuid = "550e8400-e29b-41d4-a716-446655440000" + assert {:ok, ^uuid} = Identifiers.expand_issue_id(uuid, output: "json") + end end end diff --git a/documents/phase-12-json-output-decision.adoc b/documents/phase-12-json-output-decision.adoc index 40301f0..4863d1a 100644 --- a/documents/phase-12-json-output-decision.adoc +++ b/documents/phase-12-json-output-decision.adoc @@ -36,3 +36,14 @@ commands. The shared prompt renderer writes prompts to stdout. That output would precede the JSON value and make it invalid for scripts that parse stdout. + +== EXT-72 addendum: bare-number issue IDs + +The shared bare-number issue-ID expander follows the same non-interactive rule +in JSON mode. An active profile, one favorite team, or the only available team +can resolve the team without a prompt. If more than one team is available, the +command writes a diagnostic to stderr and stops before any lookup or mutation. +Text-mode expansion, full identifiers, and UUID identifiers are unchanged. + +This addendum applies to every command that uses the shared bare-number path. +It does not change partial project or assignee resolution.