Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions Readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
31 changes: 27 additions & 4 deletions app/lib/linear_cli/cli/commands/issues/filter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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))

Expand All @@ -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
Expand Down
143 changes: 110 additions & 33 deletions app/lib/linear_cli/cli/commands/issues/move.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,26 +52,47 @@ 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}

defp resolve_move_team_id(key, _issues) 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),
Expand Down Expand Up @@ -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"}}

Expand Down
15 changes: 13 additions & 2 deletions app/lib/linear_cli/cli/commands/issues/mutations.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading