diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index e7c0c8e..5db243c 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -119,7 +119,13 @@ defmodule LinearCli.CLI do @nested_subcommand_aliases %{ "issue" => %{ - "relation" => %{"l" => "list", "ls" => "list", "a" => "add"} + "relation" => %{ + "l" => "list", + "ls" => "list", + "a" => "add", + "r" => "remove", + "rm" => "remove" + } } } @@ -248,6 +254,9 @@ defmodule LinearCli.CLI do defp dispatch([:issue, :relation, :add], result, halt), do: run(&Commands.issue_relation_add/1, result, halt) + defp dispatch([:issue, :relation, :remove], result, halt), + do: run(&Commands.issue_relation_remove/1, result, halt) + # A valid subcommand path that stops short of a leaf (e.g. `lc project` # with nothing after it) - Optimus itself doesn't require reaching a leaf, # it just returns an empty ParseResult, so without this clause it would @@ -967,6 +976,39 @@ defmodule LinearCli.CLI do end ] ] + ], + remove: [ + name: "remove", + about: """ + Remove a relationship from ISSUE to one or more RELATED_ISSUEs (aliases: r, rm). + + Direction table: + blocks — remove the relation where ISSUE blocks each RELATED_ISSUE + blocked-by — remove the relation where each RELATED_ISSUE blocks ISSUE + related — remove the related relation + duplicate — remove the duplicate relation + + Removing an absent relation is a per-target no-op. + If multiple stored relations match, that target fails and every matching + relation ID is listed — nothing is deleted arbitrarily. + """, + allow_unknown_args: true, + options: [ + type: [ + short: "-t", + long: "--type", + help: "Relationship type: blocks, blocked-by, related, duplicate", + required: true, + parser: fn + v when v in ["blocks", "blocked-by", "related", "duplicate"] -> + {:ok, v} + + v -> + {:error, + "must be one of: blocks, blocked-by, related, duplicate (got #{inspect(v)})"} + end + ] + ] ] ] ] diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 3b0d2d1..807eb02 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -1161,6 +1161,201 @@ defmodule LinearCli.CLI.Commands do defp truncate_message(msg, _max), do: msg + @doc """ + Removes a relationship from `ISSUE` to one or more `RELATED_ISSUE`s. + + The first element of `unknown` is the subject issue; the remaining elements + are the related issues. `--type` controls which stored relation to match: + + * `blocks` — removes the relation where subject blocks each related issue + * `blocked-by` — removes the relation where each related issue blocks subject + * `related` — removes the related relation + * `duplicate` — removes the duplicate relation + + Removing an absent relation is a per-target no-op (not an error). If more + than one stored relation matches for a target, that target fails and every + matching relation ID is listed — nothing is deleted arbitrarily. + + Each target is processed independently; partial failures do not roll back + successful deletions. All results are printed before returning; a non-zero + exit identifies the overall failure count if any target failed. + """ + @spec issue_relation_remove(Optimus.ParseResult.t()) :: :ok | {:error, term()} + def issue_relation_remove(%{unknown: []}), + do: {:error, {:smells_bad, "ISSUE and at least one RELATED_ISSUE are required"}} + + def issue_relation_remove(%{unknown: [_subject]}), + 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) + 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) + remove_single_relation(expanded_subject, expanded_related, user_type, all_relations) + end) + + print_relation_remove_results(results, options.output) + + failed_count = + Enum.count(results, fn r -> + match?({:failed, _, _}, r) or match?({:ambiguous, _, _}, r) or + match?({:self_link, _}, r) + end) + + if failed_count > 0 do + {:error, {:smells_bad, "#{failed_count} relation(s) failed to be removed"}} + else + :ok + end + end + end + + defp remove_single_relation(subject_id, related_id, _user_type, _relations) + when subject_id == related_id do + {:self_link, subject_id} + end + + defp remove_single_relation(subject_id, related_id, user_type, all_relations) do + subject_id + |> find_matching_relations(related_id, user_type, all_relations) + |> do_remove(related_id) + end + + defp do_remove([], related_id), do: {:absent, related_id} + + defp do_remove([relation], related_id) do + case Linear.delete_issue_relation(relation) do + :ok -> {:removed, related_id, relation} + {:error, reason} -> {:failed, related_id, reason} + end + end + + defp do_remove(relations, related_id) do + {:ambiguous, related_id, Enum.map(relations, & &1.id)} + end + + # Finds stored relations that match the user-facing type and the given endpoint pair. + # For `blocked-by`: the stored relation is `blocks` in the inbound direction, meaning + # the related_id issue is the source (`issue`) and subject is the destination (`related_issue`). + # For all other types: the stored relation is outbound with the subject as source. + defp find_matching_relations(_subject_id, related_id, "blocked-by", all_relations) do + Enum.filter(all_relations, fn rel -> + rel.direction == :inbound and + rel.type == "blocks" and + rel.issue != nil and + rel.issue.identifier == related_id + end) + end + + defp find_matching_relations(_subject_id, related_id, user_type, all_relations) do + Enum.filter(all_relations, fn rel -> + rel.direction == :outbound and + rel.type == user_type and + rel.related_issue != nil and + rel.related_issue.identifier == related_id + end) + end + + defp print_relation_remove_results(results, output) do + if output == "json" do + results + |> Enum.map(&relation_remove_result_to_plain/1) + |> Jason.encode!(pretty: true) + |> IO.puts() + else + Enum.each(results, &print_relation_remove_result_text/1) + end + end + + defp relation_remove_result_to_plain({:removed, related_id, relation}) do + %{ + "target" => related_id, + "status" => "removed", + "relation" => Display.relation_to_plain(relation) + } + end + + defp relation_remove_result_to_plain({:absent, related_id}) do + %{"target" => related_id, "status" => "absent"} + end + + defp relation_remove_result_to_plain({:self_link, id}) do + %{ + "target" => id, + "status" => "error", + "message" => "self-link: an issue cannot be related to itself" + } + end + + defp relation_remove_result_to_plain({:ambiguous, related_id, ids}) do + %{ + "target" => related_id, + "status" => "error", + "message" => "ambiguous: multiple matching relations found: #{Enum.join(ids, ", ")}" + } + end + + defp relation_remove_result_to_plain({:failed, related_id, reason}) do + msg = reason |> relation_remove_error_message() |> truncate_message(200) + %{"target" => related_id, "status" => "error", "message" => msg} + end + + defp print_relation_remove_result_text({:removed, _related_id, relation}) do + IO.puts(relation_remove_removed_text(relation)) + end + + defp print_relation_remove_result_text({:absent, related_id}) do + Prompt.ok("#{related_id}: relation not found (no change)") + end + + defp print_relation_remove_result_text({:self_link, id}) do + IO.puts(:stderr, "#{id}: self-link — an issue cannot be related to itself") + end + + defp print_relation_remove_result_text({:ambiguous, related_id, ids}) do + IO.puts( + :stderr, + "#{related_id}: ambiguous — #{length(ids)} matching relations: #{Enum.join(ids, ", ")}" + ) + end + + defp print_relation_remove_result_text({:failed, related_id, reason}) do + msg = relation_remove_error_message(reason) + IO.puts(:stderr, "#{related_id}: #{msg}") + end + + defp relation_remove_removed_text(%{type: "blocks", issue: issue, related_issue: related}) do + "#{issue.identifier} no longer blocks #{related.identifier}" + end + + defp relation_remove_removed_text(%{type: "related", issue: issue, related_issue: related}) do + "#{issue.identifier} is no longer related to #{related.identifier}" + end + + defp relation_remove_removed_text(%{type: "duplicate", issue: issue, related_issue: related}) do + "#{issue.identifier} is no longer a duplicate of #{related.identifier}" + end + + defp relation_remove_removed_text(%{type: type, issue: issue, related_issue: related}) do + "#{issue.identifier} is no longer a #{type} of #{related.identifier}" + end + + defp relation_remove_error_message(%Ash.Error.Unknown{ + errors: [%{value: [{:graphql_errors, [%{"message" => msg} | _]}]} | _] + }), + do: "Linear API error: #{msg}" + + defp relation_remove_error_message(%Ash.Error.Unknown{ + errors: [%Ash.Error.Unknown.UnknownError{error: "unknown error: :missing_api_key"} | _] + }), + do: "LINEAR_API_KEY is not set" + + defp relation_remove_error_message(_reason), do: "unexpected error" + defp resolve_optional_status(_issue, nil), do: {:ok, nil} defp resolve_optional_status(issue, name) do diff --git a/app/lib/linear_cli/linear.ex b/app/lib/linear_cli/linear.ex index 76ba4b4..5d30872 100644 --- a/app/lib/linear_cli/linear.ex +++ b/app/lib/linear_cli/linear.ex @@ -60,6 +60,7 @@ defmodule LinearCli.Linear do resource LinearCli.Linear.IssueRelation do define :issue_relations, action: :list, args: [:issue_id] define :create_issue_relation, action: :create, args: [:issue_id, :related_issue_id, :type] + define :delete_issue_relation, action: :destroy end end end diff --git a/app/lib/linear_cli/linear/issue_relation.ex b/app/lib/linear_cli/linear/issue_relation.ex index e31316d..13fafbe 100644 --- a/app/lib/linear_cli/linear/issue_relation.ex +++ b/app/lib/linear_cli/linear/issue_relation.ex @@ -24,6 +24,10 @@ defmodule LinearCli.Linear.IssueRelation do argument :type, :string, allow_nil?: false manual LinearCli.Linear.IssueRelation.Create end + + destroy :destroy do + manual LinearCli.Linear.IssueRelation.Destroy + end end attributes do @@ -107,6 +111,32 @@ defmodule LinearCli.Linear.IssueRelation.Create do end end +defmodule LinearCli.Linear.IssueRelation.Destroy do + @moduledoc false + use Ash.Resource.ManualDestroy + + alias LinearCli.Api + + def destroy(changeset, _opts, _context) do + relation_id = changeset.data.id + + case Api.call(document(), %{"id" => relation_id}) do + {:ok, %{"issueRelationDelete" => %{"success" => true}}} -> + {:ok, changeset.data} + + {:ok, other} -> + {:error, {:unexpected_response, other}} + + {:error, reason} -> + {:error, reason} + end + end + + defp document do + "mutation($id: String!) { issueRelationDelete(id: $id) { success entityId } }" + end +end + defmodule LinearCli.Linear.IssueRelation.Read.List do @moduledoc false use Ash.Resource.ManualRead diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index 9ada5c3..15017e8 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -4458,4 +4458,420 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert output =~ "EXT-1 is now a duplicate of EXT-2" end end + + describe "issue relation remove" do + defp remove_parse_result(subject, related_ids, type) do + %{ + unknown: [subject | related_ids], + options: %{output: "text", type: type} + } + end + + # Builds a stub that returns the given relations for the list query and + # a success response for the delete mutation. + defp remove_relations_stub(out_nodes, inv_nodes) do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + + cond do + String.contains?(body, "issueRelationDelete") -> + %{"variables" => %{"id" => id}} = decoded + + Req.Test.json(conn, %{ + "data" => %{ + "issueRelationDelete" => %{"success" => true, "entityId" => id} + } + }) + + String.contains?(body, "inverseRelations") -> + Req.Test.json(conn, %{ + "data" => %{ + "issue" => %{ + "inverseRelations" => %{ + "edges" => Enum.map(inv_nodes, &%{"node" => &1, "cursor" => "c"}), + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + } + } + }) + + true -> + Req.Test.json(conn, %{ + "data" => %{ + "issue" => %{ + "relations" => %{ + "edges" => Enum.map(out_nodes, &%{"node" => &1, "cursor" => "c"}), + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + } + } + }) + end + end) + end + + defp remove_relation_node(id, type, src_ident, rel_ident) do + %{ + "id" => id, + "type" => type, + "issue" => %{ + "id" => "i-src", + "identifier" => src_ident, + "title" => "#{src_ident} title", + "url" => "https://example.com/#{src_ident}" + }, + "relatedIssue" => %{ + "id" => "i-rel", + "identifier" => rel_ident, + "title" => "#{rel_ident} title", + "url" => "https://example.com/#{rel_ident}" + } + } + end + + test "removes a blocks relation and prints the result" do + remove_relations_stub( + [remove_relation_node("r1", "blocks", "EXT-1", "EXT-2")], + [] + ) + + output = + capture_io(fn -> + assert :ok = + Commands.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-2"], "blocks") + ) + end) + + assert output =~ "EXT-1 no longer blocks EXT-2" + end + + test "removes a related relation and prints correct text" do + remove_relations_stub( + [remove_relation_node("r1", "related", "EXT-1", "EXT-2")], + [] + ) + + output = + capture_io(fn -> + assert :ok = + Commands.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-2"], "related") + ) + end) + + assert output =~ "EXT-1 is no longer related to EXT-2" + end + + test "removes a duplicate relation and prints correct text" do + remove_relations_stub( + [remove_relation_node("r1", "duplicate", "EXT-1", "EXT-2")], + [] + ) + + output = + capture_io(fn -> + assert :ok = + Commands.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-2"], "duplicate") + ) + end) + + assert output =~ "EXT-1 is no longer a duplicate of EXT-2" + end + + test "absent relation is a no-op and returns :ok" do + remove_relations_stub([], []) + + output = + capture_io(fn -> + assert :ok = + Commands.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-2"], "blocks") + ) + end) + + assert output =~ "not found" + end + + test "blocked-by matches the inbound blocks relation" do + # EXT-3 blocks EXT-1: stored as an inbound blocks relation on EXT-1 + # The relation node from Linear's perspective: issue=EXT-3, relatedIssue=EXT-1 + remove_relations_stub( + [], + [remove_relation_node("r1", "blocks", "EXT-3", "EXT-1")] + ) + + output = + capture_io(fn -> + assert :ok = + Commands.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-3"], "blocked-by") + ) + end) + + assert output =~ "EXT-3 no longer blocks EXT-1" + end + + test "blocked-by with no matching inbound relation is a no-op" do + remove_relations_stub([], []) + + output = + capture_io(fn -> + assert :ok = + Commands.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-3"], "blocked-by") + ) + end) + + assert output =~ "not found" + end + + test "processes multiple related issues independently" do + remove_relations_stub( + [ + remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), + remove_relation_node("r2", "blocks", "EXT-1", "EXT-3") + ], + [] + ) + + output = + capture_io(fn -> + assert :ok = + Commands.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-2", "EXT-3"], "blocks") + ) + end) + + assert output =~ "EXT-1 no longer blocks EXT-2" + assert output =~ "EXT-1 no longer blocks EXT-3" + end + + test "rejects self-link without calling the delete mutation" do + # The list call is allowed; the delete mutation must not be called. + remove_relations_stub([], []) + + output_stderr = + capture_io(:stderr, fn -> + result = + Commands.issue_relation_remove(remove_parse_result("EXT-1", ["EXT-1"], "blocks")) + + assert {:error, {:smells_bad, _}} = result + end) + + assert output_stderr =~ "self-link" + end + + test "returns error when no related issues provided" do + assert {:error, {:smells_bad, _}} = + Commands.issue_relation_remove(%{ + unknown: ["EXT-1"], + options: %{output: "text", type: "blocks"} + }) + end + + test "returns error when no issue ids provided" do + assert {:error, {:smells_bad, _}} = + Commands.issue_relation_remove(%{ + unknown: [], + options: %{output: "text", type: "blocks"} + }) + end + + test "ambiguous match fails that target and lists all matching ids" do + # Two separate blocks relations to EXT-2 (legacy/bug state) + remove_relations_stub( + [ + remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), + remove_relation_node("r2", "blocks", "EXT-1", "EXT-2") + ], + [] + ) + + output_stderr = + capture_io(:stderr, fn -> + result = + capture_io(fn -> + Commands.issue_relation_remove(remove_parse_result("EXT-1", ["EXT-2"], "blocks")) + end) + + _ = result + end) + + assert output_stderr =~ "ambiguous" + assert output_stderr =~ "r1" + assert output_stderr =~ "r2" + end + + test "ambiguous match exits non-zero" do + remove_relations_stub( + [ + remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), + remove_relation_node("r2", "blocks", "EXT-1", "EXT-2") + ], + [] + ) + + {result, _output} = + with_io(fn -> + Commands.issue_relation_remove(remove_parse_result("EXT-1", ["EXT-2"], "blocks")) + end) + + assert {:error, {:smells_bad, msg}} = result + assert msg =~ "failed" + end + + test "partial failure: succeeds for absent target, errors for ambiguous" do + remove_relations_stub( + [ + remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), + remove_relation_node("r2", "blocks", "EXT-1", "EXT-2") + ], + [] + ) + + {result, _output} = + with_io(fn -> + Commands.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-2", "EXT-3"], "blocks") + ) + end) + + assert {:error, {:smells_bad, _}} = result + end + + test "API error on delete causes that target to fail" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + + if String.contains?(body, "issueRelationDelete") do + Req.Test.json(conn, %{"errors" => [%{"message" => "Unauthorized"}]}) + else + data = + if String.contains?(body, "inverseRelations") do + %{ + "data" => %{ + "issue" => %{ + "inverseRelations" => %{ + "edges" => [], + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + } + } + } + else + %{ + "data" => %{ + "issue" => %{ + "relations" => %{ + "edges" => [ + %{ + "node" => remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), + "cursor" => "c" + } + ], + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + } + } + } + end + + Req.Test.json(conn, data) + end + end) + + {result, _output} = + with_io(fn -> + Commands.issue_relation_remove(remove_parse_result("EXT-1", ["EXT-2"], "blocks")) + end) + + assert {:error, {:smells_bad, _}} = result + end + + test "JSON output shows removed status with relation for success" do + remove_relations_stub( + [remove_relation_node("r1", "blocks", "EXT-1", "EXT-2")], + [] + ) + + output = + capture_io(fn -> + Commands.issue_relation_remove(%{ + unknown: ["EXT-1", "EXT-2"], + options: %{output: "json", type: "blocks"} + }) + end) + + [entry] = Jason.decode!(output) + assert entry["status"] == "removed" + assert entry["target"] == "EXT-2" + assert entry["relation"]["type"] == "blocks" + end + + test "JSON output shows absent status for missing relation" do + remove_relations_stub([], []) + + output = + capture_io(fn -> + Commands.issue_relation_remove(%{ + unknown: ["EXT-1", "EXT-2"], + options: %{output: "json", type: "blocks"} + }) + end) + + [entry] = Jason.decode!(output) + assert entry["status"] == "absent" + assert entry["target"] == "EXT-2" + end + + test "JSON output shows error with all ids for ambiguous match" do + remove_relations_stub( + [ + remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), + remove_relation_node("r2", "blocks", "EXT-1", "EXT-2") + ], + [] + ) + + output = + capture_io(fn -> + Commands.issue_relation_remove(%{ + unknown: ["EXT-1", "EXT-2"], + options: %{output: "json", type: "blocks"} + }) + end) + + [entry] = Jason.decode!(output) + assert entry["status"] == "error" + assert entry["target"] == "EXT-2" + assert entry["message"] =~ "ambiguous" + assert entry["message"] =~ "r1" + assert entry["message"] =~ "r2" + end + + test "JSON output shows error for self-link" do + # The list call is allowed; the delete mutation must not be called. + remove_relations_stub([], []) + + output = + capture_io(:stderr, fn -> + output_stdout = + capture_io(fn -> + Commands.issue_relation_remove(%{ + unknown: ["EXT-1", "EXT-1"], + options: %{output: "json", type: "blocks"} + }) + end) + + [entry] = Jason.decode!(output_stdout) + assert entry["status"] == "error" + assert entry["message"] =~ "self-link" + end) + + assert output == "" + end + end end diff --git a/app/test/linear_cli/cli_test.exs b/app/test/linear_cli/cli_test.exs index f9590bc4..71de1f9 100644 --- a/app/test/linear_cli/cli_test.exs +++ b/app/test/linear_cli/cli_test.exs @@ -325,6 +325,22 @@ defmodule LinearCli.CLITest do "--type", "blocks" ]) == ["issue", "relation", "add", "EXT-1", "EXT-2", "--type", "blocks"] + + assert LinearCli.CLI.normalize_subcommand_aliases(["issue", "relation", "r", "EXT-1"]) == + ["issue", "relation", "remove", "EXT-1"] + + assert LinearCli.CLI.normalize_subcommand_aliases(["issue", "relation", "rm", "EXT-1"]) == + ["issue", "relation", "remove", "EXT-1"] + + assert LinearCli.CLI.normalize_subcommand_aliases([ + "i", + "relation", + "rm", + "EXT-1", + "EXT-2", + "--type", + "blocks" + ]) == ["issue", "relation", "remove", "EXT-1", "EXT-2", "--type", "blocks"] end test "aliased subcommands actually dispatch end to end" do diff --git a/app/test/linear_cli/linear/issue_relation_test.exs b/app/test/linear_cli/linear/issue_relation_test.exs index 970378a..82c4ed7 100644 --- a/app/test/linear_cli/linear/issue_relation_test.exs +++ b/app/test/linear_cli/linear/issue_relation_test.exs @@ -273,4 +273,70 @@ defmodule LinearCli.Linear.IssueRelationTest do %{"issueId" => "EXT-1", "relatedIssueId" => "EXT-2", "type" => "blocks"}} end end + + describe "delete_issue_relation/1" do + defp delete_success_response(entity_id) do + %{ + "data" => %{ + "issueRelationDelete" => %{ + "success" => true, + "entityId" => entity_id + } + } + } + end + + defp make_relation(id) do + IssueRelation.from_map( + %{ + "id" => id, + "type" => "blocks", + "issue" => %{"id" => "i1", "identifier" => "EXT-1", "title" => "t", "url" => "u"}, + "relatedIssue" => %{"id" => "i2", "identifier" => "EXT-2", "title" => "t", "url" => "u"} + }, + :outbound + ) + end + + test "deletes a relation and returns :ok" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, delete_success_response("rel-1")) + end) + + assert :ok = Linear.delete_issue_relation(make_relation("rel-1")) + end + + test "sends the relation id to the API" do + parent = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"variables" => vars} = Jason.decode!(body) + send(parent, {:vars, vars}) + Req.Test.json(conn, delete_success_response("rel-xyz")) + end) + + Linear.delete_issue_relation(make_relation("rel-xyz")) + + assert_received {:vars, %{"id" => "rel-xyz"}} + end + + test "returns error when API returns a graphql error" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, %{"errors" => [%{"message" => "Unauthorized"}]}) + end) + + assert {:error, _} = Linear.delete_issue_relation(make_relation("rel-1")) + end + + test "returns error on unexpected response shape" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, %{ + "data" => %{"issueRelationDelete" => %{"success" => false, "entityId" => nil}} + }) + end) + + assert {:error, _} = Linear.delete_issue_relation(make_relation("rel-1")) + end + end end diff --git a/documents/ash-domain-erd.adoc b/documents/ash-domain-erd.adoc index fe39ac2..a8fd711 100644 --- a/documents/ash-domain-erd.adoc +++ b/documents/ash-domain-erd.adoc @@ -406,6 +406,13 @@ manual-implementation module, and the Linear GraphQL operation it calls. | `Linear.IssueRelation.Create` | `issueRelationCreate(input: { issueId, relatedIssueId, type })` mutation; returns `issueRelation { id type issue { ... } relatedIssue { ... } }`. Arguments: `issue_id` (string), `related_issue_id` (string), `type` (string, wire GraphQL enum value: `blocks`, `related`, `duplicate`). The `:create` action always receives the wire-direction values; `blocked-by` reversal happens in the CLI layer. Duplicate relation errors from Linear are surfaced to the CLI as `{:duplicate_relation, message}`. +| `IssueRelation` +| `delete_issue_relation` +| `:destroy` +| destroy +| `Linear.IssueRelation.Destroy` +| `issueRelationDelete(id: $id)` mutation returning `DeletePayload { success entityId }`. Takes an `IssueRelation` struct as its argument; the relation UUID is read from `changeset.data.id`. The CLI layer resolves the exact relation (by listing the subject issue's relations and matching by wire type, direction, and endpoint identifier) before calling this interface, so no resolution logic lives in the domain layer. + | `Label` | `labels_by_names` | `:by_names`