diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 8c2c6b4..e7c0c8e 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -119,7 +119,7 @@ defmodule LinearCli.CLI do @nested_subcommand_aliases %{ "issue" => %{ - "relation" => %{"l" => "list", "ls" => "list"} + "relation" => %{"l" => "list", "ls" => "list", "a" => "add"} } } @@ -245,6 +245,9 @@ defmodule LinearCli.CLI do defp dispatch([:issue, :relation, :list], result, halt), do: run(&Commands.issue_relation_list/1, result, halt) + defp dispatch([:issue, :relation, :add], result, halt), + do: run(&Commands.issue_relation_add/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 @@ -933,6 +936,37 @@ defmodule LinearCli.CLI do required: true ] ] + ], + add: [ + name: "add", + about: """ + Add a relationship from ISSUE to one or more RELATED_ISSUEs (alias: a). + + Direction table: + blocks — ISSUE blocks each RELATED_ISSUE + blocked-by — ISSUE is blocked by each RELATED_ISSUE + related — ISSUE is related to each RELATED_ISSUE + duplicate — ISSUE is a duplicate of each RELATED_ISSUE + + Adding an already-existing identical relation is a no-op. + """, + 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 b4c942d..3b0d2d1 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -1007,6 +1007,160 @@ defmodule LinearCli.CLI.Commands do end end + @doc """ + Adds 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 direction: + + * `blocks` — subject blocks each related issue (wire: `blocks`, subject → related) + * `blocked-by` — subject is blocked by each related issue (wire: `blocks`, reversed: related → subject) + * `related` — subject is related to each related issue + * `duplicate` — subject is a duplicate of each related issue + + Each target is processed independently; partial failures do not roll back + successful mutations. All results are printed before returning; a non-zero + exit identifies the overall failure count if any target failed. + """ + @spec issue_relation_add(Optimus.ParseResult.t()) :: :ok | {:error, term()} + def issue_relation_add(%{unknown: []}), + do: {:error, {:smells_bad, "ISSUE and at least one RELATED_ISSUE are required"}} + + def issue_relation_add(%{unknown: [_subject]}), + 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) + user_type = options.type + + results = + Enum.map(related_ids, fn related_id -> + expanded_related = IssueHelpers.expand_issue_id(related_id) + add_single_relation(expanded_subject, expanded_related, user_type) + end) + + print_relation_add_results(results, options.output) + + failed_count = + Enum.count(results, fn r -> match?({:failed, _, _}, r) or match?({:self_link, _}, r) end) + + if failed_count > 0 do + {:error, {:smells_bad, "#{failed_count} relation(s) failed to be created"}} + else + :ok + end + end + + defp add_single_relation(subject_id, related_id, _user_type) when subject_id == related_id do + {:self_link, subject_id} + end + + defp add_single_relation(subject_id, related_id, user_type) do + {wire_issue_id, wire_related_id, wire_type, direction} = + if user_type == "blocked-by" do + {related_id, subject_id, "blocks", :inbound} + else + {subject_id, related_id, user_type, :outbound} + end + + case Linear.create_issue_relation(wire_issue_id, wire_related_id, wire_type) do + {:ok, relation} -> + {:created, related_id, %{relation | direction: direction}} + + {:error, %Ash.Error.Unknown{errors: [%{value: [{:duplicate_relation, _}]} | _]}} -> + {:exists, related_id} + + {:error, reason} -> + {:failed, related_id, reason} + end + end + + defp print_relation_add_results(results, output) do + if output == "json" do + results + |> Enum.map(&relation_add_result_to_plain/1) + |> Jason.encode!(pretty: true) + |> IO.puts() + else + Enum.each(results, &print_relation_add_result_text/1) + end + end + + defp relation_add_result_to_plain({:created, related_id, relation}) do + %{ + "target" => related_id, + "status" => "created", + "relation" => Display.relation_to_plain(relation) + } + end + + defp relation_add_result_to_plain({:exists, related_id}) do + %{"target" => related_id, "status" => "exists"} + end + + defp relation_add_result_to_plain({:self_link, id}) do + %{ + "target" => id, + "status" => "error", + "message" => "self-link: an issue cannot be related to itself" + } + end + + defp relation_add_result_to_plain({:failed, related_id, reason}) do + msg = reason |> relation_add_error_message() |> truncate_message(200) + %{"target" => related_id, "status" => "error", "message" => msg} + end + + defp print_relation_add_result_text({:created, _related_id, relation}) do + IO.puts(relation_add_created_text(relation)) + end + + defp print_relation_add_result_text({:exists, related_id}) do + Prompt.ok("#{related_id}: relation already exists (no change)") + end + + defp print_relation_add_result_text({:self_link, id}) do + IO.puts(:stderr, "#{id}: self-link — an issue cannot be related to itself") + end + + defp print_relation_add_result_text({:failed, related_id, reason}) do + msg = relation_add_error_message(reason) + IO.puts(:stderr, "#{related_id}: #{msg}") + end + + defp relation_add_created_text(%{type: "blocks", issue: issue, related_issue: related}) do + "#{issue.identifier} now blocks #{related.identifier}" + end + + defp relation_add_created_text(%{type: "related", issue: issue, related_issue: related}) do + "#{issue.identifier} is now related to #{related.identifier}" + end + + defp relation_add_created_text(%{type: "duplicate", issue: issue, related_issue: related}) do + "#{issue.identifier} is now a duplicate of #{related.identifier}" + end + + defp relation_add_created_text(%{type: type, issue: issue, related_issue: related}) do + "#{issue.identifier} is now a #{type} of #{related.identifier}" + end + + defp relation_add_error_message(%Ash.Error.Unknown{ + errors: [%{value: [{:graphql_errors, [%{"message" => msg} | _]}]} | _] + }), + do: "Linear API error: #{msg}" + + defp relation_add_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_add_error_message(_reason), do: "unexpected error" + + defp truncate_message(msg, max) when byte_size(msg) > max, + do: String.slice(msg, 0, max) <> "…" + + defp truncate_message(msg, _max), do: msg + defp resolve_optional_status(_issue, nil), do: {:ok, nil} defp resolve_optional_status(issue, name) do diff --git a/app/lib/linear_cli/cli/display.ex b/app/lib/linear_cli/cli/display.ex index d238df0..7c2842e 100644 --- a/app/lib/linear_cli/cli/display.ex +++ b/app/lib/linear_cli/cli/display.ex @@ -185,6 +185,9 @@ defmodule LinearCli.CLI.Display do " #{String.pad_trailing(identifier, 10)} #{title} [#{type}/#{id}]" end + @doc "Returns a plain-map representation of an IssueRelation suitable for JSON encoding." + def relation_to_plain(%IssueRelation{} = relation), do: to_plain(relation) + defp to_plain(list) when is_list(list), do: Enum.map(list, &to_plain/1) defp to_plain(%_struct{} = record) do diff --git a/app/lib/linear_cli/linear.ex b/app/lib/linear_cli/linear.ex index 666c967..76ba4b4 100644 --- a/app/lib/linear_cli/linear.ex +++ b/app/lib/linear_cli/linear.ex @@ -59,6 +59,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] end end end diff --git a/app/lib/linear_cli/linear/issue_relation.ex b/app/lib/linear_cli/linear/issue_relation.ex index f6d525b..e31316d 100644 --- a/app/lib/linear_cli/linear/issue_relation.ex +++ b/app/lib/linear_cli/linear/issue_relation.ex @@ -17,6 +17,13 @@ defmodule LinearCli.Linear.IssueRelation do argument :issue_id, :string, allow_nil?: false manual LinearCli.Linear.IssueRelation.Read.List end + + create :create do + argument :issue_id, :string, allow_nil?: false + argument :related_issue_id, :string, allow_nil?: false + argument :type, :string, allow_nil?: false + manual LinearCli.Linear.IssueRelation.Create + end end attributes do @@ -57,6 +64,49 @@ defmodule LinearCli.Linear.IssueRelation do end end +defmodule LinearCli.Linear.IssueRelation.Create do + @moduledoc false + use Ash.Resource.ManualCreate + + alias LinearCli.Api + alias LinearCli.Linear.IssueRelation + + def create(changeset, _opts, _context) do + args = changeset.arguments + + case Api.call(document(), %{ + "issueId" => args.issue_id, + "relatedIssueId" => args.related_issue_id, + "type" => args.type + }) do + {:ok, %{"issueRelationCreate" => %{"issueRelation" => rel_map}}} when is_map(rel_map) -> + {:ok, IssueRelation.from_map(rel_map, :outbound)} + + {:ok, other} -> + {:error, {:unexpected_response, other}} + + {:error, {:graphql_errors, [%{"message" => message} | _] = errors}} -> + if duplicate_error?(message) do + {:error, {:duplicate_relation, message}} + else + {:error, {:graphql_errors, errors}} + end + + {:error, reason} -> + {:error, reason} + end + end + + defp duplicate_error?(message) do + message = String.downcase(message) + String.contains?(message, "already exists") or String.contains?(message, "duplicate") + end + + defp document do + "mutation($issueId: String!, $relatedIssueId: String!, $type: IssueRelationType!) { issueRelationCreate(input: { issueId: $issueId, relatedIssueId: $relatedIssueId, type: $type }) { issueRelation { #{IssueRelation.relation_fields()} } success } }" + 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 7056cce..9ada5c3 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -4158,4 +4158,304 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert entry["direction"] == "outbound" end end + + describe "issue relation add" do + defp create_success_response(id, type, src_ident, rel_ident) do + %{ + "data" => %{ + "issueRelationCreate" => %{ + "success" => true, + "issueRelation" => %{ + "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 + + defp create_duplicate_response do + %{ + "errors" => [ + %{"message" => "A relation of this type already exists between these issues"} + ] + } + end + + defp create_error_response(message) do + %{"errors" => [%{"message" => message}]} + end + + defp add_parse_result(subject, related_ids, type) do + %{ + unknown: [subject | related_ids], + options: %{output: "text", type: type} + } + end + + test "creates a blocks relation and prints the result" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_success_response("r1", "blocks", "EXT-1", "EXT-2")) + end) + + output = + capture_io(fn -> + Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "blocks")) + end) + + assert output =~ "EXT-1 now blocks EXT-2" + end + + test "blocked-by sends reversed endpoints to Linear" 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, create_success_response("r1", "blocks", "EXT-3", "EXT-1")) + end) + + capture_io(fn -> + Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-3"], "blocked-by")) + end) + + assert_received {:vars, + %{"issueId" => "EXT-3", "relatedIssueId" => "EXT-1", "type" => "blocks"}} + end + + test "blocked-by prints direction from subject's perspective" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_success_response("r1", "blocks", "EXT-3", "EXT-1")) + end) + + output = + capture_io(fn -> + Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-3"], "blocked-by")) + end) + + assert output =~ "EXT-3 now blocks EXT-1" + end + + test "processes multiple related issues" do + call_count = :counters.new(1, []) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + :counters.add(call_count, 1, 1) + %{"variables" => %{"relatedIssueId" => related}} = Jason.decode!(body) + Req.Test.json(conn, create_success_response("r#{related}", "blocks", "EXT-1", related)) + end) + + output = + capture_io(fn -> + Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-2", "EXT-3"], "blocks")) + end) + + assert :counters.get(call_count, 1) == 2 + assert output =~ "EXT-1 now blocks EXT-2" + assert output =~ "EXT-1 now blocks EXT-3" + end + + test "treats duplicate relation as informative no-op" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_duplicate_response()) + end) + + output = + capture_io(fn -> + result = Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "blocks")) + assert result == :ok + end) + + assert output =~ "already exists" + end + + test "rejects self-link without calling the API" do + Req.Test.stub(LinearCli.Api, fn _conn -> + raise "should not be called" + end) + + output_stderr = + capture_io(:stderr, fn -> + result = Commands.issue_relation_add(add_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_add(%{ + 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_add(%{ + unknown: [], + options: %{output: "text", type: "blocks"} + }) + end + + test "exits non-zero on full failure and prints to stderr" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_error_response("Unauthorized")) + end) + + {result, _output} = + with_io(fn -> + Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "blocks")) + end) + + assert {:error, {:smells_bad, msg}} = result + assert msg =~ "failed" + end + + test "partial failure: succeeds for valid targets, errors for failed targets" do + call_count = :counters.new(1, []) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + :counters.add(call_count, 1, 1) + %{"variables" => %{"relatedIssueId" => related}} = Jason.decode!(body) + + if related == "EXT-2" do + Req.Test.json(conn, create_success_response("r1", "blocks", "EXT-1", "EXT-2")) + else + Req.Test.json(conn, create_error_response("Unauthorized")) + end + end) + + output = + capture_io(fn -> + result = + Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-2", "EXT-bad"], "blocks")) + + assert {:error, {:smells_bad, _}} = result + end) + + assert output =~ "EXT-1 now blocks EXT-2" + assert :counters.get(call_count, 1) == 2 + end + + test "JSON output contains per-target status and relation for success" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_success_response("r1", "blocks", "EXT-1", "EXT-2")) + end) + + output = + capture_io(fn -> + Commands.issue_relation_add(%{ + unknown: ["EXT-1", "EXT-2"], + options: %{output: "json", type: "blocks"} + }) + end) + + [entry] = Jason.decode!(output) + assert entry["status"] == "created" + assert entry["target"] == "EXT-2" + assert entry["relation"]["type"] == "blocks" + end + + test "JSON output shows exists status for duplicate" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_duplicate_response()) + end) + + output = + capture_io(fn -> + Commands.issue_relation_add(%{ + unknown: ["EXT-1", "EXT-2"], + options: %{output: "json", type: "blocks"} + }) + end) + + [entry] = Jason.decode!(output) + assert entry["status"] == "exists" + assert entry["target"] == "EXT-2" + end + + test "JSON output shows error status for self-link" do + Req.Test.stub(LinearCli.Api, fn _conn -> raise "should not be called" end) + + output = + capture_io(:stderr, fn -> + output_stdout = + capture_io(fn -> + Commands.issue_relation_add(%{ + unknown: ["EXT-1", "EXT-1"], + options: %{output: "json", type: "blocks"} + }) + end) + + [entry] = Jason.decode!(output_stdout) + assert entry["status"] == "error" + assert entry["target"] == "EXT-1" + assert entry["message"] =~ "self-link" + end) + + assert output == "" + end + + test "JSON output shows actual error message for API failures" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_error_response("Unauthorized")) + end) + + {_result, output} = + with_io(fn -> + Commands.issue_relation_add(%{ + 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"] =~ "Unauthorized" + end + + test "creates a related relation and prints grammatically correct text" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_success_response("r1", "related", "EXT-1", "EXT-2")) + end) + + output = + capture_io(fn -> + Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "related")) + end) + + assert output =~ "EXT-1 is now related to EXT-2" + end + + test "creates a duplicate relation and prints grammatically correct text" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_success_response("r1", "duplicate", "EXT-1", "EXT-2")) + end) + + output = + capture_io(fn -> + Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "duplicate")) + end) + + assert output =~ "EXT-1 is now a duplicate of EXT-2" + end + end end diff --git a/app/test/linear_cli/cli_test.exs b/app/test/linear_cli/cli_test.exs index d1d98fc..f9590bc4 100644 --- a/app/test/linear_cli/cli_test.exs +++ b/app/test/linear_cli/cli_test.exs @@ -306,6 +306,25 @@ defmodule LinearCli.CLITest do ["issue", "take", "CRY-1"] assert LinearCli.CLI.normalize_subcommand_aliases([]) == [] + + assert LinearCli.CLI.normalize_subcommand_aliases(["issue", "relation", "ls", "EXT-1"]) == + ["issue", "relation", "list", "EXT-1"] + + assert LinearCli.CLI.normalize_subcommand_aliases(["issue", "relation", "l", "EXT-1"]) == + ["issue", "relation", "list", "EXT-1"] + + assert LinearCli.CLI.normalize_subcommand_aliases(["issue", "relation", "a", "EXT-1"]) == + ["issue", "relation", "add", "EXT-1"] + + assert LinearCli.CLI.normalize_subcommand_aliases([ + "i", + "relation", + "a", + "EXT-1", + "EXT-2", + "--type", + "blocks" + ]) == ["issue", "relation", "add", "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 f91d017..970378a 100644 --- a/app/test/linear_cli/linear/issue_relation_test.exs +++ b/app/test/linear_cli/linear/issue_relation_test.exs @@ -198,4 +198,79 @@ defmodule LinearCli.Linear.IssueRelationTest do assert rel.issue.identifier == "EXT-3" assert rel.related_issue.identifier == "EXT-1" end + + describe "create_issue_relation/3" do + defp create_relation_payload(id, type, src_ident, rel_ident) do + %{ + "data" => %{ + "issueRelationCreate" => %{ + "success" => true, + "issueRelation" => relation_node(id, type, "i-src", src_ident, "i-rel", rel_ident) + } + } + } + end + + test "creates a relation and returns it tagged :outbound" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_relation_payload("rel-new", "blocks", "EXT-1", "EXT-2")) + end) + + assert {:ok, relation} = Linear.create_issue_relation("EXT-1", "EXT-2", "blocks") + assert relation.id == "rel-new" + assert relation.type == "blocks" + assert relation.direction == :outbound + assert relation.issue.identifier == "EXT-1" + assert relation.related_issue.identifier == "EXT-2" + end + + test "creates a related relation" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_relation_payload("rel-r", "related", "EXT-1", "EXT-3")) + end) + + assert {:ok, relation} = Linear.create_issue_relation("EXT-1", "EXT-3", "related") + assert relation.type == "related" + assert relation.direction == :outbound + 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.create_issue_relation("EXT-1", "EXT-2", "blocks") + end + + test "returns error tagged :duplicate_relation when relation already exists" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, %{ + "errors" => [ + %{"message" => "A relation of this type already exists between these issues"} + ] + }) + end) + + assert {:error, %Ash.Error.Unknown{errors: [%{value: [{:duplicate_relation, _msg}]} | _]}} = + Linear.create_issue_relation("EXT-1", "EXT-2", "blocks") + end + + test "sends issueId, relatedIssueId, and type 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, create_relation_payload("r1", "blocks", "EXT-1", "EXT-2")) + end) + + Linear.create_issue_relation("EXT-1", "EXT-2", "blocks") + + assert_received {:vars, + %{"issueId" => "EXT-1", "relatedIssueId" => "EXT-2", "type" => "blocks"}} + end + end end diff --git a/documents/ash-domain-erd.adoc b/documents/ash-domain-erd.adoc index 697c144..fe39ac2 100644 --- a/documents/ash-domain-erd.adoc +++ b/documents/ash-domain-erd.adoc @@ -399,6 +399,13 @@ manual-implementation module, and the Linear GraphQL operation it calls. | `Linear.IssueRelation.Read.List` | `issue(id: $issueId) { relations(first:, after:) { edges { node { ... } cursor } pageInfo { ... } } }` and same for `inverseRelations`, fetched concurrently; paginated up to 100 per direction +| `IssueRelation` +| `create_issue_relation` +| `:create` +| create +| `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}`. + | `Label` | `labels_by_names` | `:by_names`