Skip to content

Commit cbeff42

Browse files
feat(relations): add multi-target issue relation removal (#254)
* feat(relations): add multi-target issue relation removal Implements `lc issue relation remove ISSUE RELATED_ISSUE... --type TYPE` (aliases: r, rm) as the deletion counterpart to `relation add`. - Adds ManualDestroy action to IssueRelation backed by the `issueRelationDelete(id: String!)` GraphQL mutation - Adds `delete_issue_relation/1` domain code interface - Adds `issue_relation_remove/1` in CLI.Commands: resolves the stored relation by listing the subject's relations then matching by wire type, direction, and endpoint identifier; handles blocked-by reversal; absent relation is a per-target no-op; ambiguous match (multiple rows) fails with all matching IDs listed; each target processed independently with per-target human/JSON result reporting and non-zero exit on failure - Adds `remove` subcommand spec and dispatch; aliases r and rm - Extends IssueRelation, Commands, and CLI alias tests; updates ERD doc Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(relations): use function clauses for remove match dispatch Replace the `case matches do` in `remove_single_relation/4` with three `do_remove/2` function clauses, matching on the empty list, single-element list, and multiple-element list patterns — the idiomatic Elixir approach. No behaviour change; all 471 tests still pass. --------- Co-authored-by: bougyman's bot <ruby-automation@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a60a52e commit cbeff42

8 files changed

Lines changed: 774 additions & 1 deletion

File tree

‎app/lib/linear_cli/cli.ex‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,13 @@ defmodule LinearCli.CLI do
119119

120120
@nested_subcommand_aliases %{
121121
"issue" => %{
122-
"relation" => %{"l" => "list", "ls" => "list", "a" => "add"}
122+
"relation" => %{
123+
"l" => "list",
124+
"ls" => "list",
125+
"a" => "add",
126+
"r" => "remove",
127+
"rm" => "remove"
128+
}
123129
}
124130
}
125131

@@ -248,6 +254,9 @@ defmodule LinearCli.CLI do
248254
defp dispatch([:issue, :relation, :add], result, halt),
249255
do: run(&Commands.issue_relation_add/1, result, halt)
250256

257+
defp dispatch([:issue, :relation, :remove], result, halt),
258+
do: run(&Commands.issue_relation_remove/1, result, halt)
259+
251260
# A valid subcommand path that stops short of a leaf (e.g. `lc project`
252261
# with nothing after it) - Optimus itself doesn't require reaching a leaf,
253262
# it just returns an empty ParseResult, so without this clause it would
@@ -967,6 +976,39 @@ defmodule LinearCli.CLI do
967976
end
968977
]
969978
]
979+
],
980+
remove: [
981+
name: "remove",
982+
about: """
983+
Remove a relationship from ISSUE to one or more RELATED_ISSUEs (aliases: r, rm).
984+
985+
Direction table:
986+
blocks — remove the relation where ISSUE blocks each RELATED_ISSUE
987+
blocked-by — remove the relation where each RELATED_ISSUE blocks ISSUE
988+
related — remove the related relation
989+
duplicate — remove the duplicate relation
990+
991+
Removing an absent relation is a per-target no-op.
992+
If multiple stored relations match, that target fails and every matching
993+
relation ID is listed — nothing is deleted arbitrarily.
994+
""",
995+
allow_unknown_args: true,
996+
options: [
997+
type: [
998+
short: "-t",
999+
long: "--type",
1000+
help: "Relationship type: blocks, blocked-by, related, duplicate",
1001+
required: true,
1002+
parser: fn
1003+
v when v in ["blocks", "blocked-by", "related", "duplicate"] ->
1004+
{:ok, v}
1005+
1006+
v ->
1007+
{:error,
1008+
"must be one of: blocks, blocked-by, related, duplicate (got #{inspect(v)})"}
1009+
end
1010+
]
1011+
]
9701012
]
9711013
]
9721014
]

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

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1161,6 +1161,201 @@ defmodule LinearCli.CLI.Commands do
11611161

11621162
defp truncate_message(msg, _max), do: msg
11631163

1164+
@doc """
1165+
Removes a relationship from `ISSUE` to one or more `RELATED_ISSUE`s.
1166+
1167+
The first element of `unknown` is the subject issue; the remaining elements
1168+
are the related issues. `--type` controls which stored relation to match:
1169+
1170+
* `blocks` — removes the relation where subject blocks each related issue
1171+
* `blocked-by` — removes the relation where each related issue blocks subject
1172+
* `related` — removes the related relation
1173+
* `duplicate` — removes the duplicate relation
1174+
1175+
Removing an absent relation is a per-target no-op (not an error). If more
1176+
than one stored relation matches for a target, that target fails and every
1177+
matching relation ID is listed — nothing is deleted arbitrarily.
1178+
1179+
Each target is processed independently; partial failures do not roll back
1180+
successful deletions. All results are printed before returning; a non-zero
1181+
exit identifies the overall failure count if any target failed.
1182+
"""
1183+
@spec issue_relation_remove(Optimus.ParseResult.t()) :: :ok | {:error, term()}
1184+
def issue_relation_remove(%{unknown: []}),
1185+
do: {:error, {:smells_bad, "ISSUE and at least one RELATED_ISSUE are required"}}
1186+
1187+
def issue_relation_remove(%{unknown: [_subject]}),
1188+
do: {:error, {:smells_bad, "At least one RELATED_ISSUE is required"}}
1189+
1190+
def issue_relation_remove(%{unknown: [subject_id | related_ids], options: options}) do
1191+
expanded_subject = IssueHelpers.expand_issue_id(subject_id)
1192+
user_type = options.type
1193+
1194+
with {:ok, all_relations} <- Linear.issue_relations(expanded_subject) do
1195+
results =
1196+
Enum.map(related_ids, fn related_id ->
1197+
expanded_related = IssueHelpers.expand_issue_id(related_id)
1198+
remove_single_relation(expanded_subject, expanded_related, user_type, all_relations)
1199+
end)
1200+
1201+
print_relation_remove_results(results, options.output)
1202+
1203+
failed_count =
1204+
Enum.count(results, fn r ->
1205+
match?({:failed, _, _}, r) or match?({:ambiguous, _, _}, r) or
1206+
match?({:self_link, _}, r)
1207+
end)
1208+
1209+
if failed_count > 0 do
1210+
{:error, {:smells_bad, "#{failed_count} relation(s) failed to be removed"}}
1211+
else
1212+
:ok
1213+
end
1214+
end
1215+
end
1216+
1217+
defp remove_single_relation(subject_id, related_id, _user_type, _relations)
1218+
when subject_id == related_id do
1219+
{:self_link, subject_id}
1220+
end
1221+
1222+
defp remove_single_relation(subject_id, related_id, user_type, all_relations) do
1223+
subject_id
1224+
|> find_matching_relations(related_id, user_type, all_relations)
1225+
|> do_remove(related_id)
1226+
end
1227+
1228+
defp do_remove([], related_id), do: {:absent, related_id}
1229+
1230+
defp do_remove([relation], related_id) do
1231+
case Linear.delete_issue_relation(relation) do
1232+
:ok -> {:removed, related_id, relation}
1233+
{:error, reason} -> {:failed, related_id, reason}
1234+
end
1235+
end
1236+
1237+
defp do_remove(relations, related_id) do
1238+
{:ambiguous, related_id, Enum.map(relations, & &1.id)}
1239+
end
1240+
1241+
# Finds stored relations that match the user-facing type and the given endpoint pair.
1242+
# For `blocked-by`: the stored relation is `blocks` in the inbound direction, meaning
1243+
# the related_id issue is the source (`issue`) and subject is the destination (`related_issue`).
1244+
# For all other types: the stored relation is outbound with the subject as source.
1245+
defp find_matching_relations(_subject_id, related_id, "blocked-by", all_relations) do
1246+
Enum.filter(all_relations, fn rel ->
1247+
rel.direction == :inbound and
1248+
rel.type == "blocks" and
1249+
rel.issue != nil and
1250+
rel.issue.identifier == related_id
1251+
end)
1252+
end
1253+
1254+
defp find_matching_relations(_subject_id, related_id, user_type, all_relations) do
1255+
Enum.filter(all_relations, fn rel ->
1256+
rel.direction == :outbound and
1257+
rel.type == user_type and
1258+
rel.related_issue != nil and
1259+
rel.related_issue.identifier == related_id
1260+
end)
1261+
end
1262+
1263+
defp print_relation_remove_results(results, output) do
1264+
if output == "json" do
1265+
results
1266+
|> Enum.map(&relation_remove_result_to_plain/1)
1267+
|> Jason.encode!(pretty: true)
1268+
|> IO.puts()
1269+
else
1270+
Enum.each(results, &print_relation_remove_result_text/1)
1271+
end
1272+
end
1273+
1274+
defp relation_remove_result_to_plain({:removed, related_id, relation}) do
1275+
%{
1276+
"target" => related_id,
1277+
"status" => "removed",
1278+
"relation" => Display.relation_to_plain(relation)
1279+
}
1280+
end
1281+
1282+
defp relation_remove_result_to_plain({:absent, related_id}) do
1283+
%{"target" => related_id, "status" => "absent"}
1284+
end
1285+
1286+
defp relation_remove_result_to_plain({:self_link, id}) do
1287+
%{
1288+
"target" => id,
1289+
"status" => "error",
1290+
"message" => "self-link: an issue cannot be related to itself"
1291+
}
1292+
end
1293+
1294+
defp relation_remove_result_to_plain({:ambiguous, related_id, ids}) do
1295+
%{
1296+
"target" => related_id,
1297+
"status" => "error",
1298+
"message" => "ambiguous: multiple matching relations found: #{Enum.join(ids, ", ")}"
1299+
}
1300+
end
1301+
1302+
defp relation_remove_result_to_plain({:failed, related_id, reason}) do
1303+
msg = reason |> relation_remove_error_message() |> truncate_message(200)
1304+
%{"target" => related_id, "status" => "error", "message" => msg}
1305+
end
1306+
1307+
defp print_relation_remove_result_text({:removed, _related_id, relation}) do
1308+
IO.puts(relation_remove_removed_text(relation))
1309+
end
1310+
1311+
defp print_relation_remove_result_text({:absent, related_id}) do
1312+
Prompt.ok("#{related_id}: relation not found (no change)")
1313+
end
1314+
1315+
defp print_relation_remove_result_text({:self_link, id}) do
1316+
IO.puts(:stderr, "#{id}: self-link — an issue cannot be related to itself")
1317+
end
1318+
1319+
defp print_relation_remove_result_text({:ambiguous, related_id, ids}) do
1320+
IO.puts(
1321+
:stderr,
1322+
"#{related_id}: ambiguous — #{length(ids)} matching relations: #{Enum.join(ids, ", ")}"
1323+
)
1324+
end
1325+
1326+
defp print_relation_remove_result_text({:failed, related_id, reason}) do
1327+
msg = relation_remove_error_message(reason)
1328+
IO.puts(:stderr, "#{related_id}: #{msg}")
1329+
end
1330+
1331+
defp relation_remove_removed_text(%{type: "blocks", issue: issue, related_issue: related}) do
1332+
"#{issue.identifier} no longer blocks #{related.identifier}"
1333+
end
1334+
1335+
defp relation_remove_removed_text(%{type: "related", issue: issue, related_issue: related}) do
1336+
"#{issue.identifier} is no longer related to #{related.identifier}"
1337+
end
1338+
1339+
defp relation_remove_removed_text(%{type: "duplicate", issue: issue, related_issue: related}) do
1340+
"#{issue.identifier} is no longer a duplicate of #{related.identifier}"
1341+
end
1342+
1343+
defp relation_remove_removed_text(%{type: type, issue: issue, related_issue: related}) do
1344+
"#{issue.identifier} is no longer a #{type} of #{related.identifier}"
1345+
end
1346+
1347+
defp relation_remove_error_message(%Ash.Error.Unknown{
1348+
errors: [%{value: [{:graphql_errors, [%{"message" => msg} | _]}]} | _]
1349+
}),
1350+
do: "Linear API error: #{msg}"
1351+
1352+
defp relation_remove_error_message(%Ash.Error.Unknown{
1353+
errors: [%Ash.Error.Unknown.UnknownError{error: "unknown error: :missing_api_key"} | _]
1354+
}),
1355+
do: "LINEAR_API_KEY is not set"
1356+
1357+
defp relation_remove_error_message(_reason), do: "unexpected error"
1358+
11641359
defp resolve_optional_status(_issue, nil), do: {:ok, nil}
11651360

11661361
defp resolve_optional_status(issue, name) do

‎app/lib/linear_cli/linear.ex‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ defmodule LinearCli.Linear do
6060
resource LinearCli.Linear.IssueRelation do
6161
define :issue_relations, action: :list, args: [:issue_id]
6262
define :create_issue_relation, action: :create, args: [:issue_id, :related_issue_id, :type]
63+
define :delete_issue_relation, action: :destroy
6364
end
6465
end
6566
end

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ defmodule LinearCli.Linear.IssueRelation do
2424
argument :type, :string, allow_nil?: false
2525
manual LinearCli.Linear.IssueRelation.Create
2626
end
27+
28+
destroy :destroy do
29+
manual LinearCli.Linear.IssueRelation.Destroy
30+
end
2731
end
2832

2933
attributes do
@@ -107,6 +111,32 @@ defmodule LinearCli.Linear.IssueRelation.Create do
107111
end
108112
end
109113

114+
defmodule LinearCli.Linear.IssueRelation.Destroy do
115+
@moduledoc false
116+
use Ash.Resource.ManualDestroy
117+
118+
alias LinearCli.Api
119+
120+
def destroy(changeset, _opts, _context) do
121+
relation_id = changeset.data.id
122+
123+
case Api.call(document(), %{"id" => relation_id}) do
124+
{:ok, %{"issueRelationDelete" => %{"success" => true}}} ->
125+
{:ok, changeset.data}
126+
127+
{:ok, other} ->
128+
{:error, {:unexpected_response, other}}
129+
130+
{:error, reason} ->
131+
{:error, reason}
132+
end
133+
end
134+
135+
defp document do
136+
"mutation($id: String!) { issueRelationDelete(id: $id) { success entityId } }"
137+
end
138+
end
139+
110140
defmodule LinearCli.Linear.IssueRelation.Read.List do
111141
@moduledoc false
112142
use Ash.Resource.ManualRead

0 commit comments

Comments
 (0)