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
25 changes: 13 additions & 12 deletions app/lib/linear_cli/cli/commands.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ defmodule LinearCli.CLI.Commands do

alias LinearCli.Browser
alias LinearCli.CLI.{Display, IssueHelpers, Projects, Prompt, WhatFor}
alias LinearCli.CLI.Issue.Identifiers
alias LinearCli.{Favorites, Git, Linear, Profiles}

@max_concurrent_issue_updates 20
Expand Down Expand Up @@ -273,7 +274,7 @@ defmodule LinearCli.CLI.Commands do
include_labels = Map.get(flags, :include_labels, false) || label_filter != []

input = %{
ids: Enum.map(ids, &IssueHelpers.expand_issue_id/1),
ids: Enum.map(ids, &Identifiers.expand_issue_id/1),
mine: !flags.no_mine,
unassigned: flags.unassigned,
team_key: team_key,
Expand Down Expand Up @@ -310,7 +311,7 @@ defmodule LinearCli.CLI.Commands do
def issue_view(result, opts \\ [])

def issue_view(%{args: %{issue_id: issue_id}, flags: flags, options: options}, opts) do
expanded_id = IssueHelpers.expand_issue_id(issue_id)
expanded_id = Identifiers.expand_issue_id(issue_id)

with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}) do
if flags.web do
Expand Down Expand Up @@ -554,7 +555,7 @@ defmodule LinearCli.CLI.Commands do
:ok <- validate_body_file_exclusion(options, :description, "--description"),
{:ok, description} <- resolve_body_from_file(options, :description),
{:ok, issues} <-
Linear.issues(%{ids: Enum.map(issue_ids, &IssueHelpers.expand_issue_id/1)}) do
Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}) do
update_opts = [
comment: options.comment,
description: description,
Expand Down Expand Up @@ -603,7 +604,7 @@ defmodule LinearCli.CLI.Commands do
:ok <- validate_body_file_exclusion(options, :comment, "--comment"),
{:ok, comment_text} <- resolve_body_from_file(options, :comment),
{:ok, issues} <-
Linear.issues(%{ids: Enum.map(issue_ids, &IssueHelpers.expand_issue_id/1)}),
Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}),
body = WhatFor.comment_for(hd(issues), comment_text),
{:ok, pairs} <- add_comments_to_issues(issues, body) do
unless options.output == "json" do
Expand Down Expand Up @@ -697,7 +698,7 @@ defmodule LinearCli.CLI.Commands do
defp move_issues_by_id(issue_ids, options, flags) do
with :ok <- validate_issue_ids(issue_ids),
{:ok, issues} <-
Linear.issues(%{ids: Enum.map(issue_ids, &IssueHelpers.expand_issue_id/1)}),
Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}),
{:ok, project} <- resolve_move_project(issues, options) do
print_move_plan(issues, project, options.output)
execute_moves_if_confirmed(issues, project, flags, options.output)
Expand Down Expand Up @@ -890,7 +891,7 @@ defmodule LinearCli.CLI.Commands do
def issue_status(%{unknown: issue_ids, options: options}) do
with :ok <- validate_issue_ids(issue_ids),
{:ok, issues} <-
Linear.issues(%{ids: Enum.map(issue_ids, &IssueHelpers.expand_issue_id/1)}),
Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}),
{: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)
Expand Down Expand Up @@ -1012,7 +1013,7 @@ defmodule LinearCli.CLI.Commands 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 = IssueHelpers.expand_issue_id(issue_id)
expanded_id = Identifiers.expand_issue_id(issue_id)

with {:ok, relations} <- Linear.issue_relations(expanded_id) do
Display.show(relations, %{output: options.output, relations: true})
Expand Down Expand Up @@ -1043,12 +1044,12 @@ defmodule LinearCli.CLI.Commands do
do: {:error, {:smells_bad, "At least one RELATED_ISSUE is required"}}

def issue_relation_add(%{unknown: [subject_id | related_ids], options: options}) do
expanded_subject = IssueHelpers.expand_issue_id(subject_id)
expanded_subject = Identifiers.expand_issue_id(subject_id)
user_type = options.type

results =
Enum.map(related_ids, fn related_id ->
expanded_related = IssueHelpers.expand_issue_id(related_id)
expanded_related = Identifiers.expand_issue_id(related_id)
add_single_relation(expanded_subject, expanded_related, user_type)
end)

Expand Down Expand Up @@ -1201,13 +1202,13 @@ defmodule LinearCli.CLI.Commands do
do: {:error, {:smells_bad, "At least one RELATED_ISSUE is required"}}

def issue_relation_remove(%{unknown: [subject_id | related_ids], options: options}) do
expanded_subject = IssueHelpers.expand_issue_id(subject_id)
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 = IssueHelpers.expand_issue_id(related_id)
expanded_related = Identifiers.expand_issue_id(related_id)
remove_single_relation(expanded_subject, expanded_related, user_type, all_relations)
end)

Expand Down Expand Up @@ -1387,7 +1388,7 @@ defmodule LinearCli.CLI.Commands do
"""
@spec issue_assign(Optimus.ParseResult.t()) :: :ok | {:error, term()}
def issue_assign(%{args: %{issue_id: issue_id}, options: options}) do
expanded_id = IssueHelpers.expand_issue_id(issue_id)
expanded_id = Identifiers.expand_issue_id(issue_id)

with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}),
{:ok, members} <- Linear.team_members(issue.team.id),
Expand Down
62 changes: 62 additions & 0 deletions app/lib/linear_cli/cli/issue/identifiers.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
defmodule LinearCli.CLI.Issue.Identifiers do
@moduledoc """
Bare-issue-ID expansion: turns a plain integer string (e.g. `"1234"`) into
a team-prefixed identifier (`"CRY-1234"`) by resolving a team key.

Extracted from `LinearCli.CLI.IssueHelpers`. The single public function,
`expand_issue_id/1`, is called by every command that accepts an issue
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`).
"""

alias LinearCli.CLI.{Prompt, WhatFor}
alias LinearCli.{Favorites, 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
# look like an id at all (a UUID) passes through `expand_issue_id/1`
# unchanged.
@bare_issue_id_regex ~r/^\d+$/

@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
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`).
"""
@spec expand_issue_id(String.t()) :: String.t()
def expand_issue_id(issue_id) do
if Regex.match?(@bare_issue_id_regex, issue_id) do
"#{resolve_bare_team()}-#{issue_id}"
else
issue_id
end
end

defp resolve_bare_team do
case Profiles.default_team() do
nil -> resolve_bare_team_from_favorites()
team_key -> team_key
end
end

defp resolve_bare_team_from_favorites 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}))
end
end
end
132 changes: 132 additions & 0 deletions app/lib/linear_cli/cli/issue/workflow_states.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
defmodule LinearCli.CLI.Issue.WorkflowStates do
@moduledoc """
Workflow-state selection and status-name matching for issue lifecycle
commands.

Extracted from `LinearCli.CLI.IssueHelpers`. Provides two entry points for
type-filtered state selection (`cancelled_state_for/2`,
`completed_state_for/2`) and one shared entry point for arbitrary
name/prefix matching (`resolve_workflow_state/2`), which is also called
directly by `LinearCli.CLI.IssueHelpers.gimme_da_issue!/2` to resolve the
`--status` option without duplicating the matching logic.

## Return shapes

All public functions return `{:ok, result} | {:error, term()}`.
`{:error, {:smells_bad, message}}` is returned for user-visible failures
(no matching state, ambiguous prefix, unknown status name) — the same
tagged-tuple convention as `LinearCli.CLI.IssueHelpers`.

## State selection / prompt behavior

- A single matching state is returned directly without prompting.
- Multiple matching states with no `status` argument prompt the user via
`LinearCli.CLI.Prompt.select/2`.
- A `status` argument bypasses the prompt and resolves by exact
case-insensitive name or unique prefix.
"""

alias LinearCli.CLI.Prompt
alias LinearCli.Linear

@doc """
Resolves `issue`'s team's cancelled workflow state.

When `status` is `nil`, returns the sole cancelled state directly or prompts
among several. When `status` is given, selects by case-insensitive exact name
or unique prefix instead.

Returns `{:error, {:smells_bad, message}}` if the team has no cancelled-type
workflow state.

Ported from the combination of Ruby's `BaseModel#cancelled_states` and
`CLI::WhatFor#cancelled_state_for`.
"""
@spec cancelled_state_for(%Linear.Issue{}, String.t() | nil) ::
{:ok, %Linear.WorkflowState{}} | {:error, term()}
def cancelled_state_for(issue, status \\ nil),
do: workflow_state_for(issue, ["cancelled", "canceled"], "cancelled", status)

@doc """
Resolves `issue`'s team's completed workflow state.

When `status` is `nil`, returns the sole completed state directly or prompts
among several. When `status` is given, selects by case-insensitive exact name
or unique prefix instead.

Returns `{:error, {:smells_bad, message}}` if the team has no completed-type
workflow state.

Ported from the combination of Ruby's `BaseModel#completed_states` and
`CLI::WhatFor#completed_state_for`.
"""
@spec completed_state_for(%Linear.Issue{}, String.t() | nil) ::
{:ok, %Linear.WorkflowState{}} | {:error, term()}
def completed_state_for(issue, status \\ nil),
do: workflow_state_for(issue, ["completed"], "completed", status)

@doc """
Resolves a workflow state from `states` by case-insensitive exact `name`
match, falling back to a unique prefix match if no exact match is found.

Returns `{:ok, state}` for an unambiguous match, or
`{:error, {:smells_bad, message}}` for zero matches (unknown status) or
multiple prefix matches (ambiguous status).

Public so that callers outside this module (e.g.
`LinearCli.CLI.IssueHelpers.gimme_da_issue!/2` resolving `--status`) can
use the same matching logic without duplicating it.
"""
@spec resolve_workflow_state([%Linear.WorkflowState{}], String.t()) ::
{:ok, %Linear.WorkflowState{}} | {:error, term()}
def resolve_workflow_state(states, name) do
normalized = String.downcase(name)

states
|> Enum.filter(&(String.downcase(&1.name) == normalized))
|> use_prefix_state_matches_if_empty(states, normalized)
|> resolve_workflow_state_matches(states, name)
end

defp workflow_state_for(issue, types, label, status) do
with {:ok, states} <- Linear.workflow_states_by_team(issue.team.id) do
states
|> Enum.filter(&(&1.type in types))
|> select_workflow_state(issue, label, status)
end
end

defp select_workflow_state([], issue, label, _status) do
smells_bad("No #{label} workflow states found for team #{issue.team.key || issue.team.id}")
end

defp select_workflow_state([state], _issue, _label, nil), do: {:ok, state}

defp select_workflow_state(states, _issue, label, nil) do
{:ok, Prompt.select("Choose a #{label} state", Enum.map(states, &{&1.name, &1}))}
end

defp select_workflow_state(states, _issue, _label, status) do
resolve_workflow_state(states, status)
end

defp use_prefix_state_matches_if_empty([], states, name) do
Enum.filter(states, &String.starts_with?(String.downcase(&1.name), name))
end

defp use_prefix_state_matches_if_empty(matches, _states, _name), do: matches

defp resolve_workflow_state_matches([state], _states, _name), do: {:ok, state}

defp resolve_workflow_state_matches([], states, name) do
available = Enum.map_join(states, ", ", & &1.name)
smells_bad("Unknown status #{inspect(name)}. Available: #{available}")
end

defp resolve_workflow_state_matches(matches, _states, name) do
ambiguous = Enum.map_join(matches, ", ", & &1.name)
smells_bad("Ambiguous status #{inspect(name)}: matches #{ambiguous}")
end

defp smells_bad(message), do: {:error, {:smells_bad, message}}
end
Loading