diff --git a/AGENTS.md b/AGENTS.md index 8fa0af6..27c0286 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ Do not apply both to the same text. - Phase 10 Plan: documents/phase-10-plan.adoc - Phase 11 Plan: documents/phase-11-plan.adoc - Phase 12 Plan: documents/phase-12-plan.adoc +- Phase 12 JSON output decision: documents/phase-12-json-output-decision.adoc - Phase 12 confirmation decision: documents/phase-12-confirmation-decision.adoc - Phase 13 Plan: documents/phase-13-plan.adoc - Phase 14 Plan: documents/phase-14-plan.adoc diff --git a/Readme.adoc b/Readme.adoc index 82911c9..342ffdd 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -323,6 +323,10 @@ unassigned. In JSON mode, the warning goes to stderr so stdout stays valid JSON. The existing `--unassigned` issue-list filter is not a selector for this command because unassigning already-unassigned issues has no effect. +NOTE: In JSON mode, filtered unassign requires `--yes` or `--dry-run`. Partial +project or assignee matches return a diagnostic on stderr instead of prompting. +Successful commands write one JSON value to stdout. + ==== Create an issue [source,sh] @@ -460,6 +464,12 @@ $ lc issue move --from Retired --to Active --dry-run <4> <3> Move by project UUID — the way to target a project in another team <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 +prompt-required inputs return a diagnostic on stderr. A successful dry-run +writes the selected issues as one JSON value. + ==== Default team/project (profiles) Not in Ruby's `linear-cli` - save a named team/project bundle once, then diff --git a/app/lib/linear_cli/cli/commands/issues/filter.ex b/app/lib/linear_cli/cli/commands/issues/filter.ex index e23d64d..2732c17 100644 --- a/app/lib/linear_cli/cli/commands/issues/filter.ex +++ b/app/lib/linear_cli/cli/commands/issues/filter.ex @@ -89,6 +89,17 @@ defmodule LinearCli.CLI.Commands.Issues.Filter do end end + defp resolve_project_match(projects, search, :strict_non_interactive) do + case Projects.project_for_strict(projects, search) do + nil -> + {:error, + {:smells_bad, "JSON output requires an exact project match for #{inspect(search)}"}} + + project -> + {:ok, project.id} + end + end + defp resolve_assignee_id(nil, _team_key, _opts), do: {:ok, nil} defp resolve_assignee_id(assignee, _team_key, _opts) @@ -98,7 +109,12 @@ defmodule LinearCli.CLI.Commands.Issues.Filter do defp resolve_assignee_id(assignee, team_key, opts) do if Keyword.get(opts, :resolve_assignee, false) do with {:ok, members} <- assignee_members(team_key), - {:ok, member} <- resolve_assignee_member(members, assignee) do + {:ok, member} <- + resolve_assignee_member( + members, + assignee, + Keyword.get(opts, :non_interactive, false) + ) do {:ok, member.id} end else @@ -136,7 +152,7 @@ defmodule LinearCli.CLI.Commands.Issues.Filter do defp members_from_result({:ok, members_by_id}), do: {:ok, Map.values(members_by_id)} defp members_from_result(error), do: error - defp resolve_assignee_member(members, search) do + defp resolve_assignee_member(members, search, non_interactive) do normalized_search = String.downcase(search) exact = Enum.filter(members, &assignee_exact_match?(&1, normalized_search)) @@ -151,8 +167,15 @@ defmodule LinearCli.CLI.Commands.Issues.Filter do partial = Enum.filter(members, &assignee_partial_match?(&1, normalized_search)) case partial do - [] -> {:error, unknown_assignee_error(members, search)} - matches -> {:ok, Prompt.select("Assignee:", assignee_choices(matches))} + [] -> + {:error, unknown_assignee_error(members, search)} + + _matches when non_interactive -> + {:error, + {:smells_bad, "JSON output requires an exact assignee match for #{inspect(search)}"}} + + matches -> + {:ok, Prompt.select("Assignee:", assignee_choices(matches))} 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 d0f6c5f..4ecb819 100644 --- a/app/lib/linear_cli/cli/commands/issues/move.ex +++ b/app/lib/linear_cli/cli/commands/issues/move.ex @@ -52,14 +52,26 @@ defmodule LinearCli.CLI.Commands.Issues.Move do defp resolve_move_project(issues, options) do with {:ok, tid} <- resolve_move_team_id(options.team || Profiles.default_team(), issues), {:ok, projects} <- Linear.projects_by_team(tid, %{search: options.project}) do - project_result(Projects.project_for(projects, options.project), options.project) + project = + if options.output == "json" do + Projects.project_for_strict(projects, options.project) + else + Projects.project_for(projects, options.project) + end + + project_result(project, options.project, options.output) end end - defp project_result(nil, search), + defp project_result(nil, search, "json"), + do: + {:error, + {:smells_bad, "JSON output requires an exact project match for #{inspect(search)}"}} + + defp project_result(nil, search, _output), do: {:error, {:smells_bad, "No project found matching #{inspect(search)}"}} - defp project_result(project, _search), do: {:ok, project} + defp project_result(project, _search, _output), do: {:ok, project} defp resolve_move_team_id(nil, issues), do: {:ok, hd(issues).team.id} @@ -67,11 +79,20 @@ defmodule LinearCli.CLI.Commands.Issues.Move do with {:ok, team} <- Linear.find_team(key), do: {:ok, team.id} end + defp execute_moves_if_confirmed(issues, _project, %{dry_run: true}, "json") do + Display.show(one_or_many(issues), %{output: "json"}) + :ok + end + defp execute_moves_if_confirmed(_issues, _project, %{dry_run: true}, _output), do: :ok defp execute_moves_if_confirmed(issues, project, %{yes: true}, output), do: apply_moves(issues, project, output) + defp execute_moves_if_confirmed(_issues, _project, _flags, "json") do + {:error, {:smells_bad, "JSON output requires --yes or --dry-run for issue move"}} + end + defp execute_moves_if_confirmed(issues, project, _flags, output) do if Prompt.confirm_destructive?("Proceed with move?"), do: apply_moves(issues, project, output), @@ -127,58 +148,114 @@ defmodule LinearCli.CLI.Commands.Issues.Move do defp validate_issue_ids(_issue_ids), do: :ok defp move_issues_by_project(options, flags) do - team_fn = fn -> WhatFor.team_for(options.team || Profiles.default_team()) end + team_key = options.team || Profiles.default_team() + team_fn = fn -> WhatFor.team_for(team_key) end - with {:ok, source} <- resolve_bulk_project(options.from, team_fn), - {:ok, target} <- resolve_bulk_project(options.to, team_fn), + with :ok <- validate_bulk_team(team_key, options), + {:ok, source} <- resolve_bulk_project(options.from, team_fn, options.output), + {:ok, target} <- resolve_bulk_project(options.to, team_fn, options.output), :ok <- guard_different_projects(source, target), {:ok, issues} <- Linear.issues(%{project_id: source.id, mine: false, all: flags.all}) do - cond do - issues == [] -> - label = if flags.all, do: "issues", else: "open issues" - Prompt.ok("No #{label} in #{source.name} to move") - :ok - - flags.dry_run -> - Display.show(one_or_many(issues), %{output: options.output}) - Prompt.ok("Would move #{length(issues)} issue(s) from #{source.name} to #{target.name}") - :ok - - not flags.yes and - not Prompt.confirm_destructive?( - "Move #{length(issues)} issue(s) from #{source.name} to #{target.name}?" - ) -> - Prompt.warn("Move cancelled") - - true -> - with {:ok, pairs} <- apply_project_moves(issues, target) do - show_move_results(pairs, source, target, options.output) - end - end + handle_bulk_move(issues, source, target, flags, options) + end + end + + defp handle_bulk_move([], _source, _target, _flags, %{output: "json"}) do + Display.show([], %{output: "json"}) + :ok + end + + defp handle_bulk_move([], source, _target, flags, %{output: _output}) do + label = if flags.all, do: "issues", else: "open issues" + Prompt.ok("No #{label} in #{source.name} to move") + :ok + end + + defp handle_bulk_move(issues, source, target, %{dry_run: true}, options) do + Display.show(one_or_many(issues), %{output: options.output}) + print_bulk_dry_run(issues, source, target, options) + :ok + end + + defp handle_bulk_move(_issues, _source, _target, %{yes: false}, %{output: "json"}) do + {:error, {:smells_bad, "JSON output requires --yes or --dry-run for issue move"}} + end + + defp handle_bulk_move(issues, source, target, %{yes: false}, options) do + if Prompt.confirm_destructive?( + "Move #{length(issues)} issue(s) from #{source.name} to #{target.name}?" + ), + do: apply_project_moves_and_show(issues, source, target, options.output), + else: Prompt.warn("Move cancelled") + end + + defp handle_bulk_move(issues, source, target, %{yes: true}, options), + do: apply_project_moves_and_show(issues, source, target, options.output) + + defp print_bulk_dry_run(_issues, _source, _target, %{output: "json"}), do: :ok + + defp print_bulk_dry_run(issues, source, target, %{output: output}) when output != "json" do + Prompt.ok("Would move #{length(issues)} issue(s) from #{source.name} to #{target.name}") + end + + defp apply_project_moves_and_show(issues, source, target, output) do + with {:ok, pairs} <- apply_project_moves(issues, target) do + show_move_results(pairs, source, target, output) end end # UUID by structure: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (8-4-4-4-12, dashes at fixed positions) defp resolve_bulk_project( <<_::8*8, ?-, _::4*8, ?-, _::4*8, ?-, _::4*8, ?-, _::12*8>> = uuid, - _team_fn + _team_fn, + _output ) do short_name = String.slice(uuid, 0, 8) <> "…" {:ok, struct(LinearCli.Linear.Project, %{id: uuid, name: short_name})} end - defp resolve_bulk_project(value, team_fn) do + defp resolve_bulk_project(value, team_fn, output) do team = team_fn.() with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: value}), - project when not is_nil(project) <- Projects.project_for(projects, value) do + project when not is_nil(project) <- project_for_bulk(projects, value, output) do {:ok, project} else - nil -> {:error, {:smells_bad, "No project found matching #{value}"}} - {:error, reason} -> {:error, reason} + nil -> + if output == "json" do + {:error, + {:smells_bad, "JSON output requires an exact project match for #{inspect(value)}"}} + else + {:error, {:smells_bad, "No project found matching #{value}"}} + end + + {:error, reason} -> + {:error, reason} end end + defp project_for_bulk(projects, value, "json"), + do: Projects.project_for_strict(projects, value) + + defp project_for_bulk(projects, value, _output), + do: Projects.project_for(projects, value) + + defp validate_bulk_team(nil, %{output: "json", from: from, to: to}) do + if uuid?(from) and uuid?(to) do + :ok + else + {:error, + {:smells_bad, "JSON output requires --team or an active profile for bulk issue move"}} + end + end + + defp validate_bulk_team(_team_key, _options), do: :ok + + defp uuid?(<<_::8*8, ?-, _::4*8, ?-, _::4*8, ?-, _::4*8, ?-, _::12*8>>), + do: true + + defp uuid?(_value), do: false + defp guard_different_projects(%{id: id}, %{id: id}), do: {:error, {:smells_bad, "source and target are the same project"}} diff --git a/app/lib/linear_cli/cli/commands/issues/mutations.ex b/app/lib/linear_cli/cli/commands/issues/mutations.ex index 0b81c69..535f47b 100644 --- a/app/lib/linear_cli/cli/commands/issues/mutations.ex +++ b/app/lib/linear_cli/cli/commands/issues/mutations.ex @@ -186,10 +186,11 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do defp issue_unassign_by_filter(flags, options) do with {:ok, input} <- Filter.build_input(flags, options, [], - project_resolution: :strict, + project_resolution: project_resolution(options), include_labels: false, resolve_assignee: true, - assigned_only: true + assigned_only: true, + non_interactive: json_output?(options) ), {:ok, %{issues: issues, has_next_page: has_next_page}} <- Linear.issues_first_page(input) do unassign_filtered_issues(issues, has_next_page, flags, options) @@ -211,6 +212,10 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do unassign_and_show(issues, options) end + defp unassign_filtered_issues(_issues, _flags, %{output: "json"}) do + {:error, {:smells_bad, "JSON output requires --yes or --dry-run for issue unassign"}} + end + defp unassign_filtered_issues(issues, _flags, options) do if Prompt.confirm_destructive?("Unassign #{length(issues)} issue(s)?") do unassign_and_show(issues, options) @@ -301,6 +306,12 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do Enum.any?([:state, :status, :labels], &present_list?(Map.get(options, &1))) end + defp project_resolution(options) do + if json_output?(options), do: :strict_non_interactive, else: :strict + end + + defp json_output?(options), do: Map.get(options, :output, "text") == "json" + defp filter_qualifier?(flags) do Map.get(flags, :no_mine, false) or Map.get(flags, :no_profile, false) or diff --git a/app/test/linear_cli/cli/commands/issues/move_test.exs b/app/test/linear_cli/cli/commands/issues/move_test.exs index 6a9fcfd..440aac1 100644 --- a/app/test/linear_cli/cli/commands/issues/move_test.exs +++ b/app/test/linear_cli/cli/commands/issues/move_test.exs @@ -322,6 +322,126 @@ defmodule LinearCli.CLI.Commands.Issues.MoveTest do refute output =~ "->" end + test "--output json --dry-run emits the selected issue as one JSON value" do + stub_responses([ + {"issue(id: $id)", %{"data" => %{"issue" => issue_map()}}}, + {"projects(first: 100", move_team_projects()} + ]) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--project", + "Manhattan", + "--dry-run", + "--output", + "json", + "CRY-1" + ]) + end) + + assert {:ok, decoded} = Jason.decode(output) + assert decoded["identifier"] == "CRY-1" + end + + test "JSON confirmation is rejected without mutating an ID move" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, move_team_projects()) + + String.contains?(query, "issueUpdate") -> + raise "JSON confirmation must not mutate" + + true -> + raise "no stub matched query: #{query}" + end + end) + + stdout = + capture_io(fn -> + stderr = + capture_stderr(fn stderr -> + LinearCli.CLI.main( + ["issue", "move", "--project", "Manhattan", "--output", "json", "CRY-1"], + halt, + stderr: stderr + ) + end) + + send(test_pid, {:stderr, stderr}) + end) + + assert_received {:halted, 22} + assert_received {:stderr, stderr} + assert stderr =~ "JSON output requires --yes or --dry-run for issue move" + assert stdout == "" + end + + test "JSON dry-run rejects a partial target project before mutation" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, move_team_projects([move_project_map("p2", "Manhattan West")])) + + String.contains?(query, "issueUpdate") -> + raise "partial JSON project resolution must stop before mutation" + + true -> + raise "no stub matched query: #{query}" + end + end) + + stdout = + capture_io(fn -> + stderr = + capture_stderr(fn stderr -> + LinearCli.CLI.main( + [ + "issue", + "move", + "--project", + "Manhattan", + "--dry-run", + "--output", + "json", + "CRY-1" + ], + halt, + stderr: stderr + ) + end) + + send(test_pid, {:stderr, stderr}) + end) + + assert_received {:halted, 22} + assert_received {:stderr, stderr} + assert stderr =~ "JSON output requires an exact project match" + assert stdout == "" + end + test "with no issue ids, exits 22 (smells bad)" do test_pid = self() halt = fn code -> send(test_pid, {:halted, code}) end @@ -624,6 +744,160 @@ defmodule LinearCli.CLI.Commands.Issues.MoveTest do assert output =~ "Would move" end + test "--from/--to --dry-run --output json emits one JSON value without text" do + stub_responses(bulk_stub_pairs()) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--from", + "Source Project", + "--to", + "Target Project", + "--team", + "ENG", + "--dry-run", + "--output", + "json" + ]) + end) + + assert {:ok, decoded} = Jason.decode(output) + assert is_list(decoded) + assert length(decoded) == 3 + refute output =~ "Would move" + end + + test "--from/--to JSON confirmation is rejected without mutation" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + stub_responses(bulk_stub_pairs()) + + stdout = + capture_io(fn -> + stderr = + capture_stderr(fn stderr -> + LinearCli.CLI.main( + [ + "issue", + "move", + "--from", + "Source Project", + "--to", + "Target Project", + "--team", + "ENG", + "--output", + "json" + ], + halt, + stderr: stderr + ) + end) + + send(test_pid, {:stderr, stderr}) + end) + + assert_received {:halted, 22} + assert_received {:stderr, stderr} + assert stderr =~ "JSON output requires --yes or --dry-run for issue move" + assert stdout == "" + end + + test "--from/--to JSON dry-run rejects a partial project before listing issues" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "$teamId") -> + Req.Test.json( + conn, + team_projects([ + project_map("p-src", "Source Project"), + project_map("p-tgt", "Target Project") + ]) + ) + + String.contains?(query, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "issues(filter:") -> + raise "partial JSON project resolution must stop before listing issues" + + true -> + raise "no stub matched query: #{query}" + end + end) + + stdout = + capture_io(fn -> + stderr = + capture_stderr(fn stderr -> + LinearCli.CLI.main( + [ + "issue", + "move", + "--from", + "Source", + "--to", + "Target Project", + "--team", + "ENG", + "--dry-run", + "--output", + "json" + ], + halt, + stderr: stderr + ) + end) + + send(test_pid, {:stderr, stderr}) + end) + + assert_received {:halted, 22} + assert_received {:stderr, stderr} + assert stderr =~ "JSON output requires an exact project match" + assert stdout == "" + end + + test "--from/--to --output json returns an empty array when no issues match" do + pairs = + Enum.map(bulk_stub_pairs(), fn + {"issues(filter:", _response} -> {"issues(filter:", issues_response([])} + pair -> pair + end) + + stub_responses(pairs) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--from", + "Source Project", + "--to", + "Target Project", + "--team", + "ENG", + "--yes", + "--output", + "json" + ]) + end) + + assert {:ok, []} = Jason.decode(output) + end + test "--from/--to error mid-batch halts with non-zero exit" do test_pid = self() halt = fn code -> send(test_pid, {:halted, code}) end @@ -752,6 +1026,46 @@ defmodule LinearCli.CLI.Commands.Issues.MoveTest do assert output =~ "moved to" end + test "--from/--to UUID JSON dry-run needs no team lookup" do + src_uuid = "00000000-0000-1000-8000-000000000001" + tgt_uuid = "00000000-0000-1000-8000-000000000002" + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + if String.contains?(query, "$teamId") or String.contains?(query, "team(id: $id)") do + raise "UUID JSON move must not look up a team" + end + + if String.contains?(query, "issues(filter:") do + Req.Test.json(conn, issues_response(bulk_issues())) + else + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--from", + src_uuid, + "--to", + tgt_uuid, + "--dry-run", + "--output", + "json" + ]) + end) + + assert {:ok, decoded} = Jason.decode(output) + assert is_list(decoded) + assert length(decoded) == 3 + end + test "--from/--to identical source and target UUIDs error before listing" do same_uuid = "00000000-0000-1000-8000-000000000001" test_pid = self() diff --git a/app/test/linear_cli/cli/commands/issues/mutations_test.exs b/app/test/linear_cli/cli/commands/issues/mutations_test.exs index 915611f..787ea8a 100644 --- a/app/test/linear_cli/cli/commands/issues/mutations_test.exs +++ b/app/test/linear_cli/cli/commands/issues/mutations_test.exs @@ -471,6 +471,198 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do assert decoded["identifier"] == "CRY-1" end + test "filtered JSON output with --yes returns one JSON value" do + stub_responses([ + {"issues(filter:", issues_response([issue_map()])}, + {"issueUpdate", issue_updated(%{"assignee" => nil})} + ]) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "unassign", + "--output", + "json", + "--no-profile", + "--team", + "ENG", + "--state", + "started", + "--yes" + ]) + end) + + assert {:ok, decoded} = Jason.decode(output) + assert decoded["identifier"] == "CRY-1" + assert is_nil(decoded["assignee"]) + end + + test "filtered JSON confirmation is rejected without mutating" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "issues(filter:") -> + Req.Test.json(conn, issues_response([issue_map()])) + + String.contains?(query, "issueUpdate") -> + raise "JSON confirmation must not mutate" + + true -> + raise "no stub matched query: #{query}" + end + end) + + stdout = + capture_io(fn -> + stderr = + capture_stderr(fn stderr -> + LinearCli.CLI.main( + [ + "issue", + "unassign", + "--output", + "json", + "--no-profile", + "--team", + "ENG", + "--state", + "started" + ], + halt, + stderr: stderr + ) + end) + + send(test_pid, {:stderr, stderr}) + end) + + assert_received {:halted, 22} + assert_received {:stderr, stderr} + assert stderr =~ "JSON output requires --yes or --dry-run for issue unassign" + assert stdout == "" + end + + test "filtered JSON dry-run rejects a partial project before issue lookup" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, team_projects([project_map("p1", "Roadmap Q4")])) + + String.contains?(query, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "issues(filter:") -> + raise "partial JSON project resolution must stop before issue lookup" + + true -> + raise "no stub matched query: #{query}" + end + end) + + stdout = + capture_io(fn -> + stderr = + capture_stderr(fn stderr -> + LinearCli.CLI.main( + [ + "issue", + "unassign", + "--output", + "json", + "--no-profile", + "--team", + "ENG", + "--project", + "Roadmap", + "--dry-run" + ], + halt, + stderr: stderr + ) + end) + + send(test_pid, {:stderr, stderr}) + end) + + assert_received {:halted, 22} + assert_received {:stderr, stderr} + assert stderr =~ "JSON output requires an exact project match" + assert stdout == "" + end + + test "filtered JSON dry-run rejects a partial assignee before issue lookup" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "members(first: 50)") -> + Req.Test.json( + conn, + assignee_members_response([ + %{"id" => "u1", "name" => "Alice Smith", "displayName" => "alice"}, + %{"id" => "u2", "name" => "Alina Jones", "displayName" => "alina"} + ]) + ) + + String.contains?(query, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "issues(filter:") -> + raise "partial JSON assignee resolution must stop before issue lookup" + + true -> + raise "no stub matched query: #{query}" + end + end) + + stdout = + capture_io(fn -> + stderr = + capture_stderr(fn stderr -> + LinearCli.CLI.main( + [ + "issue", + "unassign", + "--output", + "json", + "--no-profile", + "--team", + "ENG", + "--assignee", + "Ali", + "--dry-run" + ], + halt, + stderr: stderr + ) + end) + + send(test_pid, {:stderr, stderr}) + end) + + assert_received {:halted, 22} + assert_received {:stderr, stderr} + assert stderr =~ "JSON output requires an exact assignee match" + assert stdout == "" + end + test "shares team, state, status, and label filters with issue list" do test_pid = self() diff --git a/documents/phase-12-json-output-decision.adoc b/documents/phase-12-json-output-decision.adoc new file mode 100644 index 0000000..40301f0 --- /dev/null +++ b/documents/phase-12-json-output-decision.adoc @@ -0,0 +1,38 @@ += Phase 12 JSON output decision +:revdate: Sep 27, 2026 +:icons: font +:env-github: +ifdef::env-github[] +:tip-caption: :bulb: +:note-caption: :information_source: +:important-caption: :heavy_exclamation_mark: +:caution-caption: :fire: +:warning-caption: :warning: +endif::[] + +== Decision + +`issue unassign --output json` and `issue move --output json` keep stdout +machine-readable. A successful invocation writes one JSON value. + +JSON mode does not prompt for confirmation or partial project and assignee +matches. It returns a diagnostic on stderr and exits before mutation. Use +`--yes` to confirm a mutation, or `--dry-run` to return the selected issues. +Exact project and assignee matches do not prompt. A name-based bulk move in +JSON mode also requires `--team` or an active profile. UUID-only bulk moves do +not need a team lookup. These rules prevent team selection from printing a +prompt to stdout. + +No-match results remain a JSON empty array. Text-mode prompts and messages are +unchanged. + +== Scope + +This decision applies only to the `issue unassign` and `issue move` command +families. It does not change the global display layer or other interactive +commands. + +== Reason + +The shared prompt renderer writes prompts to stdout. That output would precede +the JSON value and make it invalid for scripts that parse stdout.