Skip to content

Commit 0a71060

Browse files
bougymanclaude
andauthored
fix(close): make issue close/cancel idempotent when already in terminal state (#113)
## Summary - Adds `state` attribute to the `Issue` resource and includes `state { id name type }` in GraphQL queries, giving the CLI visibility into an issue's current workflow-state type. - Short-circuits `close_issue/2` and `cancel_issue/2` in `IssueHelpers`: when `issue.state.type` already matches the target (completed / cancelled), skips the reason comment and `issueUpdate` mutation, prints `"<identifier> is already <state name>"`, and returns `{:ok, issue}`. - Fixes `Issue.Update.Close` mutation payload: omits `trashed` entirely when not requested instead of sending `"trashed" => false`, which the Linear API rejects. ## Test plan - [x] `IssueHelpers.close_issue/2` no-ops and prints accessible message when issue is already completed - [x] `IssueHelpers.close_issue/2` no-ops when called with `cancel: true` and issue is already cancelled - [x] `IssueHelpers.cancel_issue/2` no-ops and prints accessible message when issue is already cancelled - [x] `Linear.close_issue/2` omits `trashed` from mutation input when not requested - [x] `Linear.close_issue/2` sends `trashed: true` only when explicitly requested - [x] `Issue.from_map/1` parses `state` from API response into a `WorkflowState` struct - [x] All 231 existing and new tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent decf951 commit 0a71060

4 files changed

Lines changed: 111 additions & 23 deletions

File tree

app/lib/linear_cli/cli/issue_helpers.ex

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,19 @@ defmodule LinearCli.CLI.IssueHelpers do
121121
"""
122122
@spec cancel_issue(%Linear.Issue{}, keyword()) :: {:ok, %Linear.Issue{}} | {:error, term()}
123123
def cancel_issue(issue, opts \\ []) do
124-
reason =
125-
WhatFor.reason_for(opts[:reason], four: "cancelling #{issue.identifier} - #{issue.title}")
126-
127-
with {:ok, _comment} <- issue_comment(issue, reason),
128-
{:ok, cancel_state} <- cancelled_state_for(issue),
129-
{:ok, updated} <- Linear.close_issue(issue, cancel_state.id, %{trash: !!opts[:trash]}) do
130-
Prompt.ok("#{issue.identifier} was cancelled")
131-
{:ok, updated}
124+
if issue.state && issue.state.type in ["cancelled", "canceled"] do
125+
Prompt.ok("#{issue.identifier} is already #{issue.state.name}")
126+
{:ok, issue}
127+
else
128+
reason =
129+
WhatFor.reason_for(opts[:reason], four: "cancelling #{issue.identifier} - #{issue.title}")
130+
131+
with {:ok, _comment} <- issue_comment(issue, reason),
132+
{:ok, cancel_state} <- cancelled_state_for(issue),
133+
{:ok, updated} <- Linear.close_issue(issue, cancel_state.id, %{trash: !!opts[:trash]}) do
134+
Prompt.ok("#{issue.identifier} was cancelled")
135+
{:ok, updated}
136+
end
132137
end
133138
end
134139

@@ -148,17 +153,25 @@ defmodule LinearCli.CLI.IssueHelpers do
148153
@spec close_issue(%Linear.Issue{}, keyword()) :: {:ok, %Linear.Issue{}} | {:error, term()}
149154
def close_issue(issue, opts \\ []) do
150155
cancelled = opts[:cancel]
151-
doing = if cancelled, do: "cancelling", else: "closing"
156+
target_types = if cancelled, do: ["cancelled", "canceled"], else: ["completed"]
152157
done = if cancelled, do: "cancelled", else: "closed"
153158

154-
reason =
155-
WhatFor.reason_for(opts[:reason], four: "#{doing} *#{issue.identifier} - #{issue.title}*")
159+
if issue.state && issue.state.type in target_types do
160+
Prompt.ok("#{issue.identifier} is already #{issue.state.name}")
161+
{:ok, issue}
162+
else
163+
doing = if cancelled, do: "cancelling", else: "closing"
156164

157-
with {:ok, _comment} <- issue_comment(issue, reason),
158-
{:ok, workflow_state} <- state_for(cancelled, issue),
159-
{:ok, updated} <- Linear.close_issue(issue, workflow_state.id, %{trash: !!opts[:trash]}) do
160-
Prompt.ok("#{issue.identifier} was #{done}")
161-
{:ok, updated}
165+
reason =
166+
WhatFor.reason_for(opts[:reason], four: "#{doing} *#{issue.identifier} - #{issue.title}*")
167+
168+
with {:ok, _comment} <- issue_comment(issue, reason),
169+
{:ok, workflow_state} <- state_for(cancelled, issue),
170+
{:ok, updated} <-
171+
Linear.close_issue(issue, workflow_state.id, %{trash: !!opts[:trash]}) do
172+
Prompt.ok("#{issue.identifier} was #{done}")
173+
{:ok, updated}
174+
end
162175
end
163176
end
164177

app/lib/linear_cli/linear/issue.ex

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,22 +55,26 @@ defmodule LinearCli.Linear.Issue do
5555
attribute :branch_name, :string, public?: true
5656
attribute :description, :string, public?: true
5757
attribute :assignee, :term, public?: true
58+
attribute :state, :term, public?: true
5859
attribute :team, :term, public?: true
5960
attribute :comments, {:array, :term}, public?: true, default: []
6061
end
6162

6263
@issue_fields "id identifier title branchName description createdAt updatedAt"
64+
@state_fields "id name type"
6365

6466
@doc "GraphQL field selection for an issue plus its assignee/team (Ruby: Issue.base_fragment)."
6567
def base_fields do
6668
"#{@issue_fields} " <>
69+
"state { #{@state_fields} } " <>
6770
"assignee { #{LinearCli.Linear.User.fields_with_teams()} } " <>
6871
"team { #{LinearCli.Linear.Team.base_fields()} }"
6972
end
7073

7174
@doc "GraphQL field selection for a fully detailed issue, incl. comments (Ruby: Issue.full_fragment)."
7275
def full_fields do
7376
"#{@issue_fields} " <>
77+
"state { #{@state_fields} } " <>
7478
"assignee { #{LinearCli.Linear.User.fields_with_teams()} } " <>
7579
"team { #{LinearCli.Linear.Team.full_fields()} } " <>
7680
"comments { nodes { #{LinearCli.Linear.Comment.base_fields()} } }"
@@ -85,6 +89,7 @@ defmodule LinearCli.Linear.Issue do
8589
branch_name: map["branchName"],
8690
description: map["description"],
8791
assignee: map["assignee"] && LinearCli.Linear.User.from_map(map["assignee"]),
92+
state: map["state"] && LinearCli.Linear.WorkflowState.from_map(map["state"]),
8893
team: map["team"] && LinearCli.Linear.Team.from_map(map["team"]),
8994
comments:
9095
Enum.map(
@@ -306,10 +311,9 @@ defmodule LinearCli.Linear.Issue.Update.Close do
306311

307312
def update(changeset, _opts, _context) do
308313
args = changeset.arguments
314+
input = %{"stateId" => args.state_id}
315+
input = if args.trash, do: Map.put(input, "trashed", true), else: input
309316

310-
Issue.Update.run(changeset.data.identifier, %{
311-
"stateId" => args.state_id,
312-
"trashed" => args.trash
313-
})
317+
Issue.Update.run(changeset.data.identifier, input)
314318
end
315319
end

app/test/linear_cli/cli/issue_helpers_test.exs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,20 @@ defmodule LinearCli.CLI.IssueHelpersTest do
182182
IssueHelpers.cancel_issue(issue(), reason: "no longer needed")
183183
end) =~ "Comment added to CRY-1"
184184
end
185+
186+
test "is a no-op when the issue is already in a cancelled state" do
187+
already_cancelled =
188+
issue(%{state: %WorkflowState{id: "s1", name: "Cancelled", type: "cancelled"}})
189+
190+
output =
191+
capture_io(fn ->
192+
assert {:ok, ^already_cancelled} =
193+
IssueHelpers.cancel_issue(already_cancelled, reason: "no longer needed")
194+
end)
195+
196+
assert output =~ "CRY-1 is already Cancelled"
197+
refute output =~ "Comment added"
198+
end
185199
end
186200

187201
describe "close_issue/2 (Ruby: CLI::Issue#close_issue)" do
@@ -221,6 +235,32 @@ defmodule LinearCli.CLI.IssueHelpersTest do
221235

222236
assert output =~ "CRY-1 was cancelled"
223237
end
238+
239+
test "is a no-op when the issue is already in a completed state" do
240+
already_done = issue(%{state: %WorkflowState{id: "s1", name: "Done", type: "completed"}})
241+
242+
output =
243+
capture_io(fn ->
244+
assert {:ok, ^already_done} = IssueHelpers.close_issue(already_done, reason: "shipped")
245+
end)
246+
247+
assert output =~ "CRY-1 is already Done"
248+
refute output =~ "Comment added"
249+
end
250+
251+
test "is a no-op when cancel: true and issue is already in a cancelled state" do
252+
already_cancelled =
253+
issue(%{state: %WorkflowState{id: "s1", name: "Cancelled", type: "cancelled"}})
254+
255+
output =
256+
capture_io(fn ->
257+
assert {:ok, ^already_cancelled} =
258+
IssueHelpers.close_issue(already_cancelled, cancel: true, reason: "nope")
259+
end)
260+
261+
assert output =~ "CRY-1 is already Cancelled"
262+
refute output =~ "Comment added"
263+
end
224264
end
225265

226266
describe "attach_project/2 (Ruby: CLI::Issue#attach_project)" do

app/test/linear_cli/linear/issue_test.exs

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,34 @@ defmodule LinearCli.Linear.IssueTest do
9191
assert issue.identifier == "CRY-2"
9292
end
9393

94+
test "issues/1 parses the issue's current state when present in the response" do
95+
Req.Test.stub(LinearCli.Api, fn conn ->
96+
{:ok, body, conn} = Plug.Conn.read_body(conn)
97+
%{"query" => query} = Jason.decode!(body)
98+
assert query =~ "state {"
99+
100+
Req.Test.json(conn, %{
101+
"data" => %{
102+
"issue" => %{
103+
"id" => "i2",
104+
"identifier" => "CRY-2",
105+
"title" => "Ship it",
106+
"branchName" => "cry-2-ship-it",
107+
"description" => nil,
108+
"assignee" => nil,
109+
"state" => %{"id" => "s1", "name" => "Done", "type" => "completed"},
110+
"team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"},
111+
"comments" => %{"nodes" => []}
112+
}
113+
}
114+
})
115+
end)
116+
117+
assert {:ok, [issue]} = Linear.issues(%{ids: ["cry-2"]})
118+
assert issue.state.type == "completed"
119+
assert issue.state.name == "Done"
120+
end
121+
94122
test "issues/1 with an unknown id returns a not_found error" do
95123
Req.Test.stub(LinearCli.Api, fn conn ->
96124
Req.Test.json(conn, %{"data" => %{"issue" => nil}})
@@ -274,15 +302,15 @@ defmodule LinearCli.Linear.IssueTest do
274302
end
275303

276304
describe "close_issue/2+" do
277-
test "defaults trashed to false when not given" do
305+
test "omits trashed from the mutation input when not given (Linear API rejects trashed: false)" do
278306
issue = struct!(LinearCli.Linear.Issue, id: "i1", identifier: "CRY-1")
279307

280308
Req.Test.stub(LinearCli.Api, fn conn ->
281309
{:ok, body, conn} = Plug.Conn.read_body(conn)
282310
%{"variables" => %{"id" => id, "input" => input}} = Jason.decode!(body)
283311

284312
assert id == "CRY-1"
285-
assert input == %{"stateId" => "s1", "trashed" => false}
313+
assert input == %{"stateId" => "s1"}
286314

287315
Req.Test.json(conn, %{
288316
"data" => %{
@@ -294,6 +322,7 @@ defmodule LinearCli.Linear.IssueTest do
294322
"branchName" => "cry-1-fix-it",
295323
"description" => nil,
296324
"assignee" => nil,
325+
"state" => %{"id" => "s1", "name" => "Done", "type" => "completed"},
297326
"team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"},
298327
"comments" => %{"nodes" => []}
299328
}
@@ -302,7 +331,9 @@ defmodule LinearCli.Linear.IssueTest do
302331
})
303332
end)
304333

305-
assert {:ok, _updated} = Linear.close_issue(issue, "s1")
334+
assert {:ok, updated} = Linear.close_issue(issue, "s1")
335+
assert updated.state.type == "completed"
336+
assert updated.state.name == "Done"
306337
end
307338

308339
test "sends trashed: true when given via opts" do

0 commit comments

Comments
 (0)