From 5ad9b994f2b78e7f98033e30c3c98c2f6e7fc202 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Mon, 24 Aug 2026 05:55:07 -0400 Subject: [PATCH 1/4] feat(issue): add lc issue move subcommand Adds `lc issue move ISSUE_ID... --project PROJECT` to move one or more issues to a target Linear project. - Resolves the target project by name/URL/ID/search term via the same fuzzy-match + prompt logic used by issue create/update and project favorite (`Projects.project_for/2`) - Accepts variadic issue IDs via `allow_unknown_args: true` (same pattern as issue take/status/update) - Prints a plan line (`ISSUE_ID -> PROJECT_NAME`) for every issue before any mutation is attempted - `--dry-run` prints the plan and exits without executing any mutations - `--yes`/`-y` skips the confirmation prompt; without it, asks "Proceed with move?" (default: yes, matching `Prompt.yes?/1` convention) - `--team`/`-t` scopes project resolution to the given team; when omitted, derives team from the first fetched issue (avoids a separate prompt) - Moves execute concurrently via `Task.async_stream` (capped at 20, matching issue status pattern) - `--output json` emits the updated issue(s) as JSON, suppressing the confirmation "moved to" messages - Aliases: `m` and `mv` (e.g. `lc issue m --project PROJ ISSUE_ID`) --- app/lib/linear_cli/cli.ex | 31 ++ app/lib/linear_cli/cli/commands.ex | 104 +++++ .../linear_cli/cli/issue_commands_test.exs | 401 ++++++++++++++++++ 3 files changed, 536 insertions(+) diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 4308044..85092c4 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -103,6 +103,8 @@ defmodule LinearCli.CLI do "dev" => "develop", "l" => "list", "ls" => "list", + "m" => "move", + "mv" => "move", "s" => "status", "st" => "status", "stat" => "status", @@ -209,6 +211,7 @@ defmodule LinearCli.CLI do do: run(&Commands.issue_develop/1, result, halt) defp dispatch([:issue, :pr], result, halt), do: run(&Commands.issue_pr/1, result, halt) + defp dispatch([:issue, :move], result, halt), do: run(&Commands.issue_move/1, result, halt) defp dispatch([:issue, :take], result, halt), do: run(&Commands.issue_take/1, result, halt) defp dispatch([:issue, :status], result, halt), do: run(&Commands.issue_status/1, result, halt) defp dispatch([:issue, :update], result, halt), do: run(&Commands.issue_update/1, result, halt) @@ -764,6 +767,34 @@ defmodule LinearCli.CLI do ] ] ], + move: [ + name: "move", + about: "Move one or more issues to a project (ISSUE_ID...)", + allow_unknown_args: true, + flags: [ + dry_run: [ + long: "--dry-run", + help: "Preview moves without executing them" + ], + yes: [ + short: "-y", + long: "--yes", + help: "Skip confirmation prompt" + ] + ], + options: [ + project: [ + short: "-p", + long: "--project", + help: "Target project name, URL, ID, or - to select from a list" + ], + team: [ + short: "-t", + long: "--team", + help: "Scope project search to this team" + ] + ] + ], update: [ name: "update", about: "Update an issue", diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index daf8985..32aa2a2 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -519,6 +519,110 @@ defmodule LinearCli.CLI.Commands do end end + @doc """ + Moves one or more issues to a target project. + + Issue IDs are captured via `allow_unknown_args: true` (same pattern as + `issue_take/2`/`issue_status/1`/`issue_update/1`). The target project is + resolved from `--project` (fuzzy-match against the team's projects via + `LinearCli.CLI.Projects.project_for/2`) or interactively if omitted. + Team is derived from `--team`, the active profile, or the first fetched + issue's team (to avoid a separate team prompt). + + With `--dry-run`, prints the planned moves without executing any mutations. + Without `--yes`, asks for confirmation before applying. + """ + @spec issue_move(Optimus.ParseResult.t()) :: :ok | {:error, term()} + def issue_move(%{unknown: issue_ids, options: options, flags: flags}) do + with :ok <- validate_issue_ids(issue_ids), + {:ok, issues} <- + Linear.issues(%{ids: Enum.map(issue_ids, &IssueHelpers.expand_issue_id/1)}), + {:ok, project} <- resolve_move_project(issues, options) do + print_move_plan(issues, project) + + if flags.dry_run do + :ok + else + if flags.yes || Prompt.yes?("Proceed with move?") do + apply_moves(issues, project, options.output) + else + Prompt.warn("Move cancelled") + :ok + end + end + end + end + + defp resolve_move_project(issues, options) do + team_key = options.team || Profiles.default_team() + + team_id = + if team_key do + case Linear.find_team(team_key) do + {:ok, team} -> team.id + {:error, reason} -> {:error, reason} + end + else + hd(issues).team.id + end + + case team_id do + {:error, reason} -> + {:error, reason} + + tid -> + search = options.project + + with {:ok, projects} <- Linear.projects_by_team(tid, %{search: search}) do + case Projects.project_for(projects, search) do + nil -> {:error, {:smells_bad, "No project found matching #{inspect(search)}"}} + project -> {:ok, project} + end + end + end + end + + defp print_move_plan(issues, project) do + Enum.each(issues, fn issue -> + Prompt.say("#{issue.identifier} -> #{project.name}") + end) + end + + defp apply_moves(issues, project, output) do + issues + |> Task.async_stream( + fn issue -> apply_move(issue, project) end, + max_concurrency: min(length(issues), @max_concurrent_issue_updates), + ordered: true, + timeout: 30_000 + ) + |> Enum.reduce_while({:ok, []}, fn + {:ok, {:ok, updated}}, {:ok, acc} -> {:cont, {:ok, [updated | acc]}} + {:ok, {:error, reason}}, _acc -> {:halt, {:error, reason}} + {:exit, reason}, _acc -> {:halt, {:error, {:task_exit, reason}}} + end) + |> case do + {:ok, updated_issues} -> + updated_issues = Enum.reverse(updated_issues) + Display.show(one_or_many(updated_issues), %{output: output}) + + if output != "json" do + Enum.each(updated_issues, fn updated -> + Prompt.ok("#{updated.identifier} moved to #{project.name}") + end) + end + + :ok + + error -> + error + end + end + + defp apply_move(issue, project) do + Linear.attach_issue_to_project(issue, project.id) + end + defp validate_issue_ids([]), do: {:error, {:smells_bad, "No issue IDs provided!"}} defp validate_issue_ids(_issue_ids), do: :ok diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index f5e0852..0b647ac 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -2285,4 +2285,405 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert input["stateId"] == "s-ip" end end + + describe "issue move" do + defp move_project_map(id \\ "p1", name \\ "Manhattan") do + %{ + "id" => id, + "name" => name, + "content" => nil, + "slugId" => "abc", + "description" => nil, + "url" => "https://linear.app/x/project/#{id}" + } + end + + defp move_team_projects(projects \\ nil) do + nodes = projects || [move_project_map()] + %{"data" => %{"team" => %{"projects" => %{"nodes" => nodes}}}} + end + + defp issue_moved(project_map \\ nil) do + project = project_map || move_project_map() + %{"data" => %{"issueUpdate" => %{"issue" => issue_map(%{"project" => project})}}} + end + + test "--project moves a single issue with --yes (no prompt)" 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, "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") -> + send(test_pid, {:project_id, decoded["variables"]["input"]["projectId"]}) + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--project", + "Manhattan", + "--yes", + "CRY-1" + ]) + end) + + assert_received {:project_id, "p1"} + assert output =~ "CRY-1 -> Manhattan" + assert output =~ "CRY-1 moved to Manhattan" + end + + test "--dry-run prints the plan but does not call issueUpdate" 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, "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") -> + send(test_pid, :mutation_called) + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--project", + "Manhattan", + "--dry-run", + "CRY-1" + ]) + end) + + refute_received :mutation_called + assert output =~ "CRY-1 -> Manhattan" + refute output =~ "moved to" + end + + test "user declines confirmation, no mutation called" 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, "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") -> + send(test_pid, :mutation_called) + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io([input: "n\n"], fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--project", + "Manhattan", + "CRY-1" + ]) + end) + + refute_received :mutation_called + assert output =~ "CRY-1 -> Manhattan" + assert output =~ "Move cancelled" + end + + test "user confirms, mutation is called" 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, "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") -> + send(test_pid, :mutation_called) + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io([input: "y\n"], fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--project", + "Manhattan", + "CRY-1" + ]) + end) + + assert_received :mutation_called + end + + test "moves multiple issues concurrently with --yes" 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, "issue(id: $id)") -> + identifier = decoded["variables"]["id"] + + issue = + issue_map(%{ + "identifier" => identifier, + "id" => "i-#{identifier}" + }) + + Req.Test.json(conn, %{"data" => %{"issue" => issue}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, move_team_projects()) + + String.contains?(query, "issueUpdate") -> + identifier = decoded["variables"]["id"] + send(test_pid, {:moved, identifier}) + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--project", + "Manhattan", + "--yes", + "CRY-1", + "CRY-2" + ]) + end) + + assert_received {:moved, "CRY-1"} + assert_received {:moved, "CRY-2"} + assert output =~ "CRY-1 -> Manhattan" + assert output =~ "CRY-2 -> Manhattan" + end + + test "--output json emits issue JSON without confirmation messages" do + 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") -> + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--project", + "Manhattan", + "--yes", + "--output", + "json", + "CRY-1" + ]) + end) + + # Plan line precedes JSON output; extract the JSON portion + json_part = output |> String.split("\n", parts: 2) |> List.last() |> String.trim() + assert {:ok, decoded} = Jason.decode(json_part) + assert decoded["identifier"] == "CRY-1" + refute output =~ "moved to" + end + + test "with no issue ids, exits 22 (smells bad)" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + output = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "move", "--project", "Manhattan"], halt) + end) + + assert_received {:halted, 22} + assert output =~ "No issue IDs provided!" + end + + test "alias 'm' routes to issue move" do + test_pid = self() + + 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") -> + send(test_pid, :moved) + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "m", "--project", "Manhattan", "--yes", "CRY-1"]) + end) + + assert_received :moved + end + + test "alias 'mv' routes to issue move" do + test_pid = self() + + 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") -> + send(test_pid, :moved) + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "mv", "--project", "Manhattan", "--yes", "CRY-1"]) + end) + + assert_received :moved + end + + test "--team scopes project search to the given team" 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + # Team.Read.Find uses "query($id: String!) { team(id: $id) { ... projects ... } }" + String.contains?(query, "team(id: $id)") && String.contains?(query, "projects") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "projects(first: 100") -> + send(test_pid, {:team_id, decoded["variables"]["teamId"]}) + Req.Test.json(conn, move_team_projects()) + + String.contains?(query, "issueUpdate") -> + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--project", + "Manhattan", + "--team", + "ENG", + "--yes", + "CRY-1" + ]) + end) + + assert_received {:team_id, "t1"} + end + end end From 80b443412041b29765cf12767864f9a52e4ff191 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Mon, 24 Aug 2026 06:14:46 -0400 Subject: [PATCH 2/4] refactor(issue): idiomatic Elixir for issue move; fix JSON output; add docs - Replace nested if/else in issue_move/1 with execute_moves_if_confirmed/4 function heads (dry_run, yes, prompt branches as separate clauses) - Replace value-as-error pattern in resolve_move_project with a with chain and resolve_move_team_id/2 function heads - Suppress plan lines in JSON output mode (print_move_plan guards on output) - Update JSON test to assert clean JSON without plan-line prefix workaround - Add issue move alias row (m, mv) to Readme alias table - Add issue move usage section to Readme Co-Authored-By: Claude Sonnet 4.6 --- Readme.adoc | 20 +++++++ app/lib/linear_cli/cli/commands.ex | 57 ++++++++----------- .../linear_cli/cli/issue_commands_test.exs | 5 +- 3 files changed, 46 insertions(+), 36 deletions(-) diff --git a/Readme.adoc b/Readme.adoc index cd4f4c3..04d9c64 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -158,6 +158,7 @@ so you don't have to type the full name every time: |`issue list` |`l`, `ls` |`issue status` |`s`, `st`, `stat` |`issue assign` |`a` +|`issue move` |`m`, `mv` |`issue update` |`u` |`issue pr` |`pull-request` |`team list` |`l`, `ls` @@ -316,6 +317,25 @@ $ lc issue status CRY-1234 -s Done -m "Wrapped up" <4> <3> Move multiple issues at once — updates run concurrently <4> Add a comment alongside the state change +==== Move issues to a project + +Moves one or more issues to a different Linear project. Prompts for the target +project when `--project`/`-p` is omitted. + +[source,sh] +---- +$ lc issue move --project Manhattan CRY-1 <1> +$ lc issue move -p Manhattan CRY-1 CRY-2 CRY-3 <2> +$ lc issue move --project Manhattan --dry-run CRY-1 <3> +$ lc issue move --project Manhattan --yes CRY-1 <4> +$ lc issue move --project Manhattan --team ENG CRY-1 <5> +---- +<1> Move a single issue; prompts for confirmation before executing +<2> Move multiple issues at once — updates run concurrently +<3> Preview the planned moves without executing any mutations +<4> Skip the confirmation prompt +<5> Scope project search to the `ENG` team + ==== 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.ex b/app/lib/linear_cli/cli/commands.ex index 32aa2a2..7afa5e7 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -538,56 +538,47 @@ defmodule LinearCli.CLI.Commands do {:ok, issues} <- Linear.issues(%{ids: Enum.map(issue_ids, &IssueHelpers.expand_issue_id/1)}), {:ok, project} <- resolve_move_project(issues, options) do - print_move_plan(issues, project) + print_move_plan(issues, project, options.output) + execute_moves_if_confirmed(issues, project, flags, options.output) + end + end - if flags.dry_run do - :ok - else - if flags.yes || Prompt.yes?("Proceed with move?") do - apply_moves(issues, project, options.output) - else - Prompt.warn("Move cancelled") - :ok - 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 + case Projects.project_for(projects, options.project) do + nil -> {:error, {:smells_bad, "No project found matching #{inspect(options.project)}"}} + project -> {:ok, project} end end end - defp resolve_move_project(issues, options) do - team_key = options.team || Profiles.default_team() + defp resolve_move_team_id(nil, issues), do: {:ok, hd(issues).team.id} - team_id = - if team_key do - case Linear.find_team(team_key) do - {:ok, team} -> team.id - {:error, reason} -> {:error, reason} - end - else - hd(issues).team.id - end + defp resolve_move_team_id(key, _issues) do + with {:ok, team} <- Linear.find_team(key), do: {:ok, team.id} + end - case team_id do - {:error, reason} -> - {:error, reason} + defp execute_moves_if_confirmed(_issues, _project, %{dry_run: true}, _output), do: :ok - tid -> - search = options.project + defp execute_moves_if_confirmed(issues, project, %{yes: true}, output), + do: apply_moves(issues, project, output) - with {:ok, projects} <- Linear.projects_by_team(tid, %{search: search}) do - case Projects.project_for(projects, search) do - nil -> {:error, {:smells_bad, "No project found matching #{inspect(search)}"}} - project -> {:ok, project} - end - end + defp execute_moves_if_confirmed(issues, project, _flags, output) do + case Prompt.yes?("Proceed with move?") do + true -> apply_moves(issues, project, output) + false -> Prompt.warn("Move cancelled") end end - defp print_move_plan(issues, project) do + defp print_move_plan(issues, project, output) when output != "json" do Enum.each(issues, fn issue -> Prompt.say("#{issue.identifier} -> #{project.name}") end) end + defp print_move_plan(_issues, _project, _output), do: :ok + defp apply_moves(issues, project, output) do issues |> Task.async_stream( diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index 0b647ac..cf6ef39 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -2559,11 +2559,10 @@ defmodule LinearCli.CLI.IssueCommandsTest do ]) end) - # Plan line precedes JSON output; extract the JSON portion - json_part = output |> String.split("\n", parts: 2) |> List.last() |> String.trim() - assert {:ok, decoded} = Jason.decode(json_part) + assert {:ok, decoded} = Jason.decode(String.trim(output)) assert decoded["identifier"] == "CRY-1" refute output =~ "moved to" + refute output =~ "->" end test "with no issue ids, exits 22 (smells bad)" do From fe26143e78bd33284ece4f1e4ad82c7e304a4e11 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Mon, 24 Aug 2026 06:37:49 -0400 Subject: [PATCH 3/4] refactor(issue): eliminate case statements in issue move Replace case-on-nil with project_result/2 multi-clause heads in resolve_move_project/2, and case-on-boolean with if in the execute_moves_if_confirmed/4 fallback clause. Co-Authored-By: Claude Sonnet 4.6 --- app/lib/linear_cli/cli/commands.ex | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 7afa5e7..f2ab449 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -546,13 +546,15 @@ defmodule LinearCli.CLI.Commands 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 - case Projects.project_for(projects, options.project) do - nil -> {:error, {:smells_bad, "No project found matching #{inspect(options.project)}"}} - project -> {:ok, project} - end + project_result(Projects.project_for(projects, options.project), options.project) end end + defp project_result(nil, search), + do: {:error, {:smells_bad, "No project found matching #{inspect(search)}"}} + + defp project_result(project, _search), do: {:ok, project} + defp resolve_move_team_id(nil, issues), do: {:ok, hd(issues).team.id} defp resolve_move_team_id(key, _issues) do @@ -565,10 +567,9 @@ defmodule LinearCli.CLI.Commands do do: apply_moves(issues, project, output) defp execute_moves_if_confirmed(issues, project, _flags, output) do - case Prompt.yes?("Proceed with move?") do - true -> apply_moves(issues, project, output) - false -> Prompt.warn("Move cancelled") - end + if Prompt.yes?("Proceed with move?"), + do: apply_moves(issues, project, output), + else: Prompt.warn("Move cancelled") end defp print_move_plan(issues, project, output) when output != "json" do From 2bf477a9330340f0f6db12df844d0f8eaf2060df Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Mon, 24 Aug 2026 07:56:42 -0400 Subject: [PATCH 4/4] refactor(issue): extract apply_moves case to multi-clause display_moves_result Co-Authored-By: Claude Sonnet 4.6 --- app/lib/linear_cli/cli/commands.ex | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index f2ab449..f461f0d 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -593,24 +593,26 @@ defmodule LinearCli.CLI.Commands do {:ok, {:error, reason}}, _acc -> {:halt, {:error, reason}} {:exit, reason}, _acc -> {:halt, {:error, {:task_exit, reason}}} end) - |> case do - {:ok, updated_issues} -> - updated_issues = Enum.reverse(updated_issues) - Display.show(one_or_many(updated_issues), %{output: output}) - - if output != "json" do - Enum.each(updated_issues, fn updated -> - Prompt.ok("#{updated.identifier} moved to #{project.name}") - end) - end + |> display_moves_result(project, output) + end - :ok + defp display_moves_result({:ok, updated_issues}, project, output) do + updated_issues = Enum.reverse(updated_issues) + Display.show(one_or_many(updated_issues), %{output: output}) + print_move_results(updated_issues, project, output) + :ok + end - error -> - error - end + defp display_moves_result(error, _project, _output), do: error + + defp print_move_results(updated_issues, project, output) when output != "json" do + Enum.each(updated_issues, fn updated -> + Prompt.ok("#{updated.identifier} moved to #{project.name}") + end) end + defp print_move_results(_updated_issues, _project, _output), do: :ok + defp apply_move(issue, project) do Linear.attach_issue_to_project(issue, project.id) end