Skip to content

Commit 20d0158

Browse files
bougymanclaude
andauthored
feat(issue): add lc issue status command (#123)
## Summary - Adds `lc issue status ISSUE_ID` to change an issue's workflow state - `--status`/`-s` sets the state by name (case-insensitive exact match, then unique prefix); prompts interactively when omitted - `--comment`/`-m` adds a comment alongside the status change - `"s"` subcommand alias mirrors other single-letter issue aliases (`c`, `d`, `l`, `u`) - Resolves ISSUE_ID via the existing `expand_issue_id/1` convention - Errors clearly on unknown or ambiguous status names (exit 22 via `{:smells_bad, …}`) - JSON output emits the updated issue struct only; text mode adds a confirmation line ## Implementation - New `:set_status` Ash action on `LinearCli.Linear.Issue` with a dedicated `LinearCli.Linear.Issue.Update.SetStatus` manual update module (mirrors the pattern of `:close`, `:assign`, `:attach_to_project`) - `set_issue_status/2` domain code interface in `LinearCli.Linear` - `status` subcommand entry in the Optimus spec with `ISSUE_ID` positional arg and `--status`/`--comment` options - `Commands.issue_status/1` orchestrates the full flow: expand id → fetch issue → fetch team states → resolve target state → optional comment → transition → display ## Test plan - [x] Exact name match (case-insensitive via `--status done`) - [x] Short `-s` flag - [x] Unique prefix match (e.g. `--status in` → "In Progress") - [x] Unknown status name → exit 22 + clear message - [x] Ambiguous prefix → exit 22 + clear message - [x] `--comment`/`-m` adds comment before transitioning - [x] Interactive selection (no `--status`): prompts sorted by position - [x] `--output json` emits parseable JSON only - [x] `"s"` alias routes correctly Closes https://linear.app/cryptokairos/issue/CRY-47/add-lc-issue-status-command 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 50bb48a commit 20d0158

5 files changed

Lines changed: 454 additions & 0 deletions

File tree

‎app/lib/linear_cli/cli.ex‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ defmodule LinearCli.CLI do
100100
"dev" => "develop",
101101
"l" => "list",
102102
"ls" => "list",
103+
"s" => "status",
103104
"u" => "update",
104105
"pull-request" => "pr"
105106
},
@@ -203,6 +204,7 @@ defmodule LinearCli.CLI do
203204

204205
defp dispatch([:issue, :pr], result, halt), do: run(&Commands.issue_pr/1, result, halt)
205206
defp dispatch([:issue, :take], result, halt), do: run(&Commands.issue_take/1, result, halt)
207+
defp dispatch([:issue, :status], result, halt), do: run(&Commands.issue_status/1, result, halt)
206208
defp dispatch([:issue, :update], result, halt), do: run(&Commands.issue_update/1, result, halt)
207209

208210
# A valid subcommand path that stops short of a leaf (e.g. `lc project`
@@ -594,6 +596,25 @@ defmodule LinearCli.CLI do
594596
description: [long: "--description", help: "The description of the PR"]
595597
]
596598
],
599+
status: [
600+
name: "status",
601+
about: "Change the workflow state of an issue",
602+
args: [
603+
issue_id: [value_name: "ISSUE_ID", help: "The Issue (i.e. CRY-1)", required: true]
604+
],
605+
options: [
606+
status: [
607+
short: "-s",
608+
long: "--status",
609+
help: "Workflow state name to set (prompts if omitted)"
610+
],
611+
comment: [
612+
short: "-m",
613+
long: "--comment",
614+
help: "Comment to add alongside the status change"
615+
]
616+
]
617+
],
597618
take: [
598619
name: "take",
599620
about: "Assign one or more issues to yourself",

‎app/lib/linear_cli/cli/commands.ex‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,4 +475,72 @@ defmodule LinearCli.CLI.Commands do
475475

476476
defp validate_issue_ids([]), do: {:error, {:smells_bad, "No issue IDs provided!"}}
477477
defp validate_issue_ids(_issue_ids), do: :ok
478+
479+
@doc """
480+
Changes the workflow state of an issue.
481+
482+
With `--status`/`-s`, matches the given name against the issue's team's
483+
workflow states (case-insensitive exact, then unique prefix). Without it,
484+
prompts interactively via `LinearCli.CLI.Prompt.select/2`.
485+
486+
With `--comment`/`-m`, adds a comment to the issue before transitioning.
487+
"""
488+
@spec issue_status(Optimus.ParseResult.t()) :: :ok | {:error, term()}
489+
def issue_status(%{args: %{issue_id: issue_id}, options: options}) do
490+
expanded_id = IssueHelpers.expand_issue_id(issue_id)
491+
492+
with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}),
493+
{:ok, states} <- Linear.workflow_states_by_team(issue.team.id),
494+
{:ok, target_state} <- resolve_target_state(states, options.status),
495+
:ok <- maybe_add_status_comment(issue, options.comment),
496+
{:ok, updated} <- Linear.set_issue_status(issue, target_state.id) do
497+
Display.show(updated, %{output: options.output})
498+
499+
if options.output != "json",
500+
do: Prompt.ok("#{updated.identifier} status set to #{target_state.name}")
501+
502+
:ok
503+
end
504+
end
505+
506+
defp resolve_target_state(states, nil) do
507+
choices = Enum.sort_by(states, & &1.position) |> Enum.map(&{&1.name, &1})
508+
{:ok, Prompt.select("Choose a status", choices)}
509+
end
510+
511+
defp resolve_target_state(states, name) do
512+
normalized_name = String.downcase(name)
513+
514+
states
515+
|> Enum.filter(&(String.downcase(&1.name) == normalized_name))
516+
|> use_prefix_matches_if_empty(states, normalized_name)
517+
|> resolve_state_matches(states, name)
518+
end
519+
520+
defp use_prefix_matches_if_empty([], states, name) do
521+
Enum.filter(states, &String.starts_with?(String.downcase(&1.name), name))
522+
end
523+
524+
defp use_prefix_matches_if_empty(matches, _states, _name), do: matches
525+
526+
defp resolve_state_matches([state], _states, _name), do: {:ok, state}
527+
528+
defp resolve_state_matches([], states, name) do
529+
available = Enum.map_join(states, ", ", & &1.name)
530+
{:error, {:smells_bad, "Unknown status #{inspect(name)}. Available: #{available}"}}
531+
end
532+
533+
defp resolve_state_matches(matches, _states, name) do
534+
ambiguous = Enum.map_join(matches, ", ", & &1.name)
535+
{:error, {:smells_bad, "Ambiguous status #{inspect(name)}: matches #{ambiguous}"}}
536+
end
537+
538+
defp maybe_add_status_comment(_issue, nil), do: :ok
539+
540+
defp maybe_add_status_comment(issue, comment) do
541+
case IssueHelpers.issue_comment(issue, comment) do
542+
{:ok, _} -> :ok
543+
{:error, reason} -> {:error, reason}
544+
end
545+
end
478546
end

‎app/lib/linear_cli/linear.ex‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ defmodule LinearCli.Linear do
3535
define :assign_issue, action: :assign, args: [:assignee_id]
3636
define :attach_issue_to_project, action: :attach_to_project, args: [:project_id]
3737
define :close_issue, action: :close, args: [:state_id]
38+
define :set_issue_status, action: :set_status, args: [:state_id]
3839
end
3940

4041
resource LinearCli.Linear.Label do

‎app/lib/linear_cli/linear/issue.ex‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ defmodule LinearCli.Linear.Issue do
4848
argument :trash, :boolean, default: false
4949
manual LinearCli.Linear.Issue.Update.Close
5050
end
51+
52+
update :set_status do
53+
argument :state_id, :string, allow_nil?: false
54+
manual LinearCli.Linear.Issue.Update.SetStatus
55+
end
5156
end
5257

5358
attributes do
@@ -360,3 +365,16 @@ defmodule LinearCli.Linear.Issue.Update.Close do
360365
Issue.Update.run(changeset.data.identifier, input)
361366
end
362367
end
368+
369+
defmodule LinearCli.Linear.Issue.Update.SetStatus do
370+
@moduledoc false
371+
use Ash.Resource.ManualUpdate
372+
373+
alias LinearCli.Linear.Issue
374+
375+
def update(changeset, _opts, _context) do
376+
Issue.Update.run(changeset.data.identifier, %{
377+
"stateId" => changeset.arguments.state_id
378+
})
379+
end
380+
end

0 commit comments

Comments
 (0)