diff --git a/app/lib/linear_cli/cli/commands/issues/filter.ex b/app/lib/linear_cli/cli/commands/issues/filter.ex index 1c8c1fb..9dbf60a 100644 --- a/app/lib/linear_cli/cli/commands/issues/filter.ex +++ b/app/lib/linear_cli/cli/commands/issues/filter.ex @@ -90,13 +90,19 @@ defmodule LinearCli.CLI.Commands.Issues.Filter do 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)}"}} + case Projects.project_scores(projects, search) do + [] -> + {:error, {:smells_bad, "No project found matching #{search}"}} - project -> - {:ok, project.id} + _possibles -> + 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 end diff --git a/app/lib/linear_cli/cli/commands/issues/move.ex b/app/lib/linear_cli/cli/commands/issues/move.ex index a34abe3..3861926 100644 --- a/app/lib/linear_cli/cli/commands/issues/move.ex +++ b/app/lib/linear_cli/cli/commands/issues/move.ex @@ -50,6 +50,10 @@ defmodule LinearCli.CLI.Commands.Issues.Move do end end + defp resolve_move_project(_issues, %{project: nil, output: "json"}) do + {:error, {:smells_bad, "JSON output requires --project for issue move"}} + end + 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 @@ -60,19 +64,23 @@ defmodule LinearCli.CLI.Commands.Issues.Move do Projects.project_for(projects, options.project) end - project_result(project, options.project, options.output) + project_result(project, projects, options.project, options.output) end end - defp project_result(nil, search, "json"), - do: + defp project_result(nil, projects, search, "json") do + if Projects.project_scores(projects, search) == [] do + {:error, {:smells_bad, "No project found matching #{search}"}} + else {:error, {:smells_bad, "JSON output requires an exact project match for #{inspect(search)}"}} + end + end - defp project_result(nil, search, _output), + defp project_result(nil, _projects, search, _output), do: {:error, {:smells_bad, "No project found matching #{inspect(search)}"}} - defp project_result(project, _search, _output), do: {:ok, project} + defp project_result(project, _projects, _search, _output), do: {:ok, project} defp resolve_move_team_id(nil, issues), do: {:ok, hd(issues).team.id} @@ -218,23 +226,25 @@ defmodule LinearCli.CLI.Commands.Issues.Move 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) <- project_for_bulk(projects, value, output) do - {:ok, project} - else - 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 + with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: value}) do + case project_for_bulk(projects, value, output) do + nil -> bulk_project_error(projects, value, output) + project -> {:ok, project} + end + end + end - {:error, reason} -> - {:error, reason} + defp bulk_project_error(projects, value, "json") do + if Projects.project_scores(projects, value) == [] do + {:error, {:smells_bad, "No project found matching #{value}"}} + else + {:error, {:smells_bad, "JSON output requires an exact project match for #{inspect(value)}"}} end end + defp bulk_project_error(_projects, value, _output), + do: {:error, {:smells_bad, "No project found matching #{value}"}} + defp project_for_bulk(projects, value, "json"), do: Projects.project_for_strict(projects, value) diff --git a/app/lib/linear_cli/cli/commands/issues/mutations.ex b/app/lib/linear_cli/cli/commands/issues/mutations.ex index 980dd9b..0ceff2e 100644 --- a/app/lib/linear_cli/cli/commands/issues/mutations.ex +++ b/app/lib/linear_cli/cli/commands/issues/mutations.ex @@ -253,13 +253,8 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do :ok end - defp cancel_unassign(options) do - if Map.get(options, :output, "text") == "json" do - Display.show([], %{output: "json"}) - else - Prompt.warn("Unassign cancelled") - end - + defp cancel_unassign(_options) do + Prompt.warn("Unassign cancelled") :ok end 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 440aac1..4cb11e3 100644 --- a/app/test/linear_cli/cli/commands/issues/move_test.exs +++ b/app/test/linear_cli/cli/commands/issues/move_test.exs @@ -442,6 +442,99 @@ defmodule LinearCli.CLI.Commands.Issues.MoveTest do assert stdout == "" end + test "JSON ID move names the missing --project option" 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, "issueUpdate") -> + raise "a missing --project must stop before mutation" + + true -> + raise "missing --project must not resolve a target project" + end + end) + + stdout = + capture_io(fn -> + stderr = + capture_stderr(fn stderr -> + LinearCli.CLI.main( + ["issue", "move", "--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 --project for issue move" + assert stdout == "" + end + + test "JSON ID move reports when no project matches" 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 "an unknown project 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", + "Missing Project", + "--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 =~ "No project found matching Missing Project" + refute stderr =~ "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 @@ -807,6 +900,46 @@ defmodule LinearCli.CLI.Commands.Issues.MoveTest do assert stdout == "" end + test "JSON bulk move requires --team or an active profile" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn _conn -> + send(test_pid, :api_called) + raise "the bulk team guard must run before any API call" + end) + + stdout = + capture_io(fn -> + stderr = + capture_stderr(fn stderr -> + LinearCli.CLI.main( + [ + "issue", + "move", + "--from", + "Source Project", + "--to", + "Target Project", + "--dry-run", + "--output", + "json" + ], + halt, + stderr: stderr + ) + end) + + send(test_pid, {:stderr, stderr}) + end) + + assert_received {:halted, 22} + assert_received {:stderr, stderr} + refute_received :api_called + assert stderr =~ "JSON output requires --team or an active profile for bulk 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 @@ -868,6 +1001,62 @@ defmodule LinearCli.CLI.Commands.Issues.MoveTest do assert stdout == "" end + test "--from/--to JSON dry-run reports when no project matches" 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([])) + + String.contains?(query, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "issues(filter:") -> + raise "an unknown project 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", + "Missing Project", + "--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 =~ "No project found matching Missing Project" + refute stderr =~ "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 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 4250972..1e6d880 100644 --- a/app/test/linear_cli/cli/commands/issues/mutations_test.exs +++ b/app/test/linear_cli/cli/commands/issues/mutations_test.exs @@ -835,6 +835,113 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do assert is_nil(decoded["assignee"]) end + test "filtered JSON unassign resolves an exact project" 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, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, team_projects([project_map("p1", "Roadmap Q4")])) + + String.contains?(query, "issues(filter:") -> + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_updated(%{"assignee" => nil})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "unassign", + "--output", + "json", + "--no-profile", + "--team", + "ENG", + "--project", + "Roadmap Q4", + "--yes" + ]) + end) + + assert {:ok, decoded} = Jason.decode(output) + assert decoded["identifier"] == "CRY-1" + assert_received {:filter, %{"project" => %{"id" => %{"eq" => "p1"}}}} + assert_received {:input, %{"assigneeId" => nil}} + end + + test "filtered JSON unassign resolves an exact assignee" 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, "members(first: 50)") -> + Req.Test.json( + conn, + assignee_members_response([ + %{"id" => "u1", "name" => "Alice Smith", "displayName" => "alice"} + ]) + ) + + String.contains?(query, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "issues(filter:") -> + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_updated(%{"assignee" => nil})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "unassign", + "--output", + "json", + "--no-profile", + "--team", + "ENG", + "--assignee", + "Alice Smith", + "--yes" + ]) + end) + + assert {:ok, decoded} = Jason.decode(output) + assert decoded["identifier"] == "CRY-1" + assert_received {:filter, %{"assignee" => %{"id" => %{"eq" => "u1"}}}} + assert_received {:input, %{"assigneeId" => nil}} + end + test "filtered JSON confirmation is rejected without mutating" do test_pid = self() halt = fn code -> send(test_pid, {:halted, code}) end @@ -939,6 +1046,61 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do assert stdout == "" end + test "filtered JSON dry-run reports when no project matches" 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([])) + + String.contains?(query, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "issues(filter:") -> + raise "an unknown project 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", + "Missing Project", + "--dry-run" + ], + halt, + stderr: stderr + ) + end) + + send(test_pid, {:stderr, stderr}) + end) + + assert_received {:halted, 22} + assert_received {:stderr, stderr} + assert stderr =~ "No project found matching Missing Project" + refute stderr =~ "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