diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 616622c..8c2c6b4 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -117,13 +117,33 @@ defmodule LinearCli.CLI do "profile" => %{"l" => "list", "ls" => "list"} } + @nested_subcommand_aliases %{ + "issue" => %{ + "relation" => %{"l" => "list", "ls" => "list"} + } + } + @doc false def normalize_subcommand_aliases([first | rest]) do canonical_first = Map.get(@command_aliases, first, first) case {Map.fetch(@subcommand_aliases, canonical_first), rest} do {{:ok, sub_aliases}, [second | more]} -> - [canonical_first, Map.get(sub_aliases, second, second) | more] + canonical_second = Map.get(sub_aliases, second, second) + + case {Map.fetch(@nested_subcommand_aliases, canonical_first), more} do + {{:ok, nested}, [third | rest2]} -> + case Map.fetch(nested, canonical_second) do + {:ok, third_aliases} -> + [canonical_first, canonical_second, Map.get(third_aliases, third, third) | rest2] + + :error -> + [canonical_first, canonical_second | more] + end + + _ -> + [canonical_first, canonical_second | more] + end _ -> [canonical_first | rest] @@ -222,6 +242,9 @@ defmodule LinearCli.CLI do defp dispatch([:issue, :status], result, halt), do: run(&Commands.issue_status/1, result, halt) defp dispatch([:issue, :update], result, halt), do: run(&Commands.issue_update/1, result, halt) + defp dispatch([:issue, :relation, :list], result, halt), + do: run(&Commands.issue_relation_list/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 @@ -895,6 +918,23 @@ defmodule LinearCli.CLI do ], reason: [long: "--reason", help: "Reason for closing the issue. - open an editor"] ] + ], + relation: [ + name: "relation", + about: "Manage issue relationships (blocks, blocked-by, related, duplicate)", + subcommands: [ + list: [ + name: "list", + about: "List relationships for an issue (alias: ls)", + args: [ + issue_id: [ + value_name: "ISSUE", + help: "The issue to list relationships for (e.g. EXT-1)", + required: true + ] + ] + ] + ] ] ] ] diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index ecb6f00..b4c942d 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -989,6 +989,24 @@ defmodule LinearCli.CLI.Commands do end end + @doc """ + Lists the relationships for a single issue — both outbound (issues this one + blocks/is-related-to/is-duplicate-of) and inbound (issues that block this + one, etc.). + + Calls `Linear.issue_relations/1` which fetches both `relations` and + `inverseRelations` from Linear and tags each with a direction. + """ + @spec issue_relation_list(Optimus.ParseResult.t()) :: :ok | {:error, term()} + def issue_relation_list(%{args: %{issue_id: issue_id}, options: options}) do + expanded_id = IssueHelpers.expand_issue_id(issue_id) + + with {:ok, relations} <- Linear.issue_relations(expanded_id) do + Display.show(relations, %{output: options.output, relations: true}) + :ok + end + end + 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 88527ad..d238df0 100644 --- a/app/lib/linear_cli/cli/display.ex +++ b/app/lib/linear_cli/cli/display.ex @@ -8,7 +8,7 @@ defmodule LinearCli.CLI.Display do """ alias LinearCli.CLI.Pager - alias LinearCli.Linear.{Comment, Issue, Project, ProjectUpdate, Team, User} + alias LinearCli.Linear.{Comment, Issue, IssueRelation, Project, ProjectUpdate, Team, User} alias LinearCli.Profiles.Profile @ash_internal_fields ~w(__meta__ __metadata__ __order__ __lateral_join_source__ aggregates calculations)a @@ -25,11 +25,21 @@ defmodule LinearCli.CLI.Display do if Map.get(opts, :output, "text") == "json" do subject |> to_plain() |> Jason.encode!(pretty: true) |> IO.puts() else - text = subject |> List.wrap() |> Enum.map_join("\n", &format(&1, opts)) + text = format_text(subject, opts) Pager.maybe_page(text, opts) end end + defp format_text([%IssueRelation{} | _] = relations, _opts), do: relations_block(relations) + + defp format_text(subject, opts) do + subject |> List.wrap() |> Enum.map_join("\n", &format(&1, opts)) + end + + defp format(%IssueRelation{} = relation, _opts) do + relation_line(relation) + end + defp format(%Team{} = team, _opts) do "#{String.pad_trailing(team.key || "", 6)} #{team.name}" end @@ -99,7 +109,13 @@ defmodule LinearCli.CLI.Display do description = render_markdown(issue.description) comments = Enum.map_join(issue.comments, "\n", &comment_block/1) - [header, sep, labels, description, comments] + all_relations = + List.wrap(Map.get(issue, :relations, [])) ++ + List.wrap(Map.get(issue, :inverse_relations, [])) + + relations_text = if all_relations != [], do: relations_block(all_relations), else: "" + + [header, sep, labels, description, comments, relations_text] |> Enum.reject(&(&1 == "")) |> Enum.join("\n") end @@ -116,6 +132,59 @@ defmodule LinearCli.CLI.Display do defp render_markdown(""), do: render_markdown("# No description for this issue") defp render_markdown(text), do: Marcli.render(text) + @direction_labels %{ + blocks: {"Blocks", :outbound, "blocks"}, + blocked_by: {"Blocked by", :inbound, "blocks"}, + related: {"Related to", nil, "related"}, + duplicate: {"Duplicate of", nil, "duplicate"}, + similar: {"Similar to", nil, "similar"} + } + + defp relations_block(relations) do + grouped = Enum.group_by(relations, &relation_section_key/1) + + section_order = [:blocks, :blocked_by, :related, :duplicate, :similar] + + section_order + |> Enum.flat_map(fn key -> + case Map.get(grouped, key) do + nil -> + [] + + rels -> + {label, _dir, _type} = @direction_labels[key] + lines = Enum.map(rels, &relation_line/1) + ["#{label}:" | lines] + end + end) + |> Enum.join("\n") + end + + defp relation_section_key(%IssueRelation{direction: :outbound, type: "blocks"}), do: :blocks + defp relation_section_key(%IssueRelation{direction: :inbound, type: "blocks"}), do: :blocked_by + defp relation_section_key(%IssueRelation{type: "related"}), do: :related + defp relation_section_key(%IssueRelation{type: "duplicate"}), do: :duplicate + defp relation_section_key(%IssueRelation{type: "similar"}), do: :similar + defp relation_section_key(%IssueRelation{}), do: :related + + defp relation_line(%IssueRelation{ + direction: direction, + type: type, + issue: src, + related_issue: rel, + id: id + }) do + other = + case direction do + :outbound -> rel + :inbound -> src + end + + identifier = (other && other.identifier) || "?" + title = (other && other.title) || "" + " #{String.pad_trailing(identifier, 10)} #{title} [#{type}/#{id}]" + end + 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 87a3894..666c967 100644 --- a/app/lib/linear_cli/linear.ex +++ b/app/lib/linear_cli/linear.ex @@ -56,5 +56,9 @@ defmodule LinearCli.Linear do resource LinearCli.Linear.ProjectUpdate do define :post_project_update, action: :create, args: [:project_id, :body] end + + resource LinearCli.Linear.IssueRelation do + define :issue_relations, action: :list, args: [:issue_id] + end end end diff --git a/app/lib/linear_cli/linear/issue.ex b/app/lib/linear_cli/linear/issue.ex index 644b6bd..dcf3f98 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -75,6 +75,8 @@ defmodule LinearCli.Linear.Issue do attribute :team, :term, public?: true attribute :comments, {:array, :term}, public?: true, default: [] attribute :labels, {:array, :term}, public?: true, default: [] + attribute :relations, {:array, :term}, public?: true, default: [] + attribute :inverse_relations, {:array, :term}, public?: true, default: [] end @issue_fields "id identifier title branchName description url createdAt updatedAt" @@ -95,12 +97,17 @@ defmodule LinearCli.Linear.Issue do @doc "GraphQL field selection for a fully detailed issue, incl. comments (Ruby: Issue.full_fragment)." def full_fields do + rel = LinearCli.Linear.IssueRelation.relation_fields() + relation_connection = "edges { node { #{rel} } cursor } pageInfo { hasNextPage endCursor }" + "#{@issue_fields} " <> "state { #{@state_fields} } " <> "assignee { #{LinearCli.Linear.User.fields_with_teams()} } " <> "team { #{LinearCli.Linear.Team.full_fields()} } " <> "comments { nodes { #{LinearCli.Linear.Comment.base_fields()} } } " <> - "labels { nodes { #{LinearCli.Linear.Label.base_fields()} } }" + "labels { nodes { #{LinearCli.Linear.Label.base_fields()} } } " <> + "relations(first: 50) { #{relation_connection} } " <> + "inverseRelations(first: 50) { #{relation_connection} }" end @doc false @@ -124,6 +131,16 @@ defmodule LinearCli.Linear.Issue do Enum.map( get_in(map, ["labels", "nodes"]) || [], &LinearCli.Linear.Label.from_map/1 + ), + relations: + Enum.map( + get_in(map, ["relations", "edges"]) || [], + &LinearCli.Linear.IssueRelation.from_map(&1["node"], :outbound) + ), + inverse_relations: + Enum.map( + get_in(map, ["inverseRelations", "edges"]) || [], + &LinearCli.Linear.IssueRelation.from_map(&1["node"], :inbound) ) ) end diff --git a/app/lib/linear_cli/linear/issue_relation.ex b/app/lib/linear_cli/linear/issue_relation.ex new file mode 100644 index 0000000..f6d525b --- /dev/null +++ b/app/lib/linear_cli/linear/issue_relation.ex @@ -0,0 +1,135 @@ +defmodule LinearCli.Linear.IssueRelation do + @moduledoc """ + A directional relation between two Linear issues (blocks, blocked-by, related, + duplicate, similar). + + No data layer — all actions call `LinearCli.Api` directly. `direction` + records whether this relation appeared in `Issue.relations` (`:outbound`) or + `Issue.inverseRelations` (`:inbound`), allowing the display layer to render + "Blocks" vs "Blocked by" without the caller having to reconstruct direction + from endpoint order. + """ + + use Ash.Resource, domain: LinearCli.Linear + + actions do + read :list do + argument :issue_id, :string, allow_nil?: false + manual LinearCli.Linear.IssueRelation.Read.List + end + end + + attributes do + attribute :id, :string, primary_key?: true, allow_nil?: false, public?: true + attribute :type, :string, public?: true + attribute :direction, :atom, public?: true + attribute :issue, :term, public?: true + attribute :related_issue, :term, public?: true + end + + @endpoint_fields "id identifier title url" + + @doc "GraphQL field selection for each issue endpoint inside a relation node." + def endpoint_fields, do: @endpoint_fields + + @doc "GraphQL field selection for a full relation node." + def relation_fields do + "id type " <> + "issue { #{endpoint_fields()} } " <> + "relatedIssue { #{endpoint_fields()} }" + end + + @doc false + def from_map(map, direction) when is_atom(direction) do + struct!(__MODULE__, + id: map["id"], + type: map["type"], + direction: direction, + issue: endpoint_from_map(map["issue"]), + related_issue: endpoint_from_map(map["relatedIssue"]) + ) + end + + defp endpoint_from_map(nil), do: nil + + defp endpoint_from_map(map) do + %{id: map["id"], identifier: map["identifier"], title: map["title"], url: map["url"]} + end +end + +defmodule LinearCli.Linear.IssueRelation.Read.List do + @moduledoc false + use Ash.Resource.ManualRead + + alias LinearCli.Api + alias LinearCli.Linear.IssueRelation + + # Fetches both outbound (`relations`) and inbound (`inverseRelations`) + # concurrently, paginating each independently up to 100 records per direction. + def read(query, _ecto_query, _opts, _context) do + issue_id = query.arguments.issue_id + + [outbound_task, inbound_task] = [ + Task.async(fn -> fetch_all(issue_id, "relations", :outbound) end), + Task.async(fn -> fetch_all(issue_id, "inverseRelations", :inbound) end) + ] + + [outbound_result, inbound_result] = Task.await_many([outbound_task, inbound_task], 30_000) + + with {:ok, outbound} <- outbound_result, + {:ok, inbound} <- inbound_result do + {:ok, outbound ++ inbound} + end + end + + defp fetch_all(issue_id, connection_field, direction) do + fetch_page(issue_id, connection_field, direction, nil, []) + end + + defp fetch_page(issue_id, connection_field, direction, after_cursor, acc) do + vars = %{"issueId" => issue_id, "first" => 50, "after" => after_cursor} + + case Api.call(document(connection_field), vars) do + {:ok, %{"issue" => nil}} -> + {:error, {:not_found, issue_id}} + + {:ok, data} -> + case get_in(data, ["issue", connection_field]) do + %{"edges" => edges, "pageInfo" => page_info} -> + decoded = Enum.map(edges, &IssueRelation.from_map(&1["node"], direction)) + acc = acc ++ decoded + + if page_info["hasNextPage"] and length(acc) < 100 do + fetch_page(issue_id, connection_field, direction, page_info["endCursor"], acc) + else + {:ok, acc} + end + + other -> + {:error, {:unexpected_response, other}} + end + + {:error, {:http_error, status, _body}} -> + {:error, {:http_error, status}} + + {:error, reason} -> + {:error, reason} + end + end + + # A function, not a module attribute: IssueRelation.relation_fields/0 is a + # cross-module call; evaluated at call time so compile-time ordering doesn't + # matter. + defp document(connection_field) do + """ + query($issueId: String!, $first: Int!, $after: String) { + issue(id: $issueId) { + #{connection_field}(first: $first, after: $after) { + edges { node { #{IssueRelation.relation_fields()} } cursor } + pageInfo { hasNextPage endCursor } + } + } + } + """ + end +end diff --git a/app/test/linear_cli/cli/display_test.exs b/app/test/linear_cli/cli/display_test.exs index 4153e05..38c0580 100644 --- a/app/test/linear_cli/cli/display_test.exs +++ b/app/test/linear_cli/cli/display_test.exs @@ -4,7 +4,7 @@ defmodule LinearCli.CLI.DisplayTest do import ExUnit.CaptureIO alias LinearCli.CLI.Display - alias LinearCli.Linear.{Issue, Label} + alias LinearCli.Linear.{Issue, IssueRelation, Label} test "full issue output syntax-highlights fenced Elixir code" do issue = %Issue{ @@ -126,6 +126,123 @@ defmodule LinearCli.CLI.DisplayTest do refute output =~ "[Bug]" end + defp relation(id, type, direction, src_ident, rel_ident) do + %IssueRelation{ + id: id, + type: type, + direction: direction, + issue: %{ + id: "i-src", + identifier: src_ident, + title: "#{src_ident} Title", + url: "https://example.com/#{src_ident}" + }, + related_issue: %{ + id: "i-rel", + identifier: rel_ident, + title: "#{rel_ident} Title", + url: "https://example.com/#{rel_ident}" + } + } + end + + test "relations list shows Blocks section for outbound blocks relations" do + rels = [relation("r1", "blocks", :outbound, "EXT-1", "EXT-2")] + output = capture_io(fn -> Display.show(rels, %{}) end) + + assert output =~ "Blocks:" + assert output =~ "EXT-2" + refute output =~ "Blocked by:" + end + + test "relations list shows Blocked by section for inbound blocks relations" do + rels = [relation("r1", "blocks", :inbound, "EXT-3", "EXT-1")] + output = capture_io(fn -> Display.show(rels, %{}) end) + + assert output =~ "Blocked by:" + assert output =~ "EXT-3" + refute output =~ "Blocks:" + end + + test "relations list shows Related to section for related type" do + rels = [relation("r1", "related", :outbound, "EXT-1", "EXT-4")] + output = capture_io(fn -> Display.show(rels, %{}) end) + + assert output =~ "Related to:" + assert output =~ "EXT-4" + end + + test "relations list shows all relation sections in order" do + rels = [ + relation("r1", "blocks", :outbound, "EXT-1", "EXT-2"), + relation("r2", "blocks", :inbound, "EXT-3", "EXT-1"), + relation("r3", "related", :outbound, "EXT-1", "EXT-4"), + relation("r4", "duplicate", :outbound, "EXT-1", "EXT-5"), + relation("r5", "similar", :outbound, "EXT-1", "EXT-6") + ] + + output = capture_io(fn -> Display.show(rels, %{}) end) + + assert output =~ "Blocks:" + assert output =~ "Blocked by:" + assert output =~ "Related to:" + assert output =~ "Duplicate of:" + assert output =~ "Similar to:" + assert String.contains?(output, "EXT-2") + assert String.contains?(output, "EXT-3") + assert String.contains?(output, "EXT-4") + assert String.contains?(output, "EXT-5") + assert String.contains?(output, "EXT-6") + end + + test "full issue view shows relations when issue has them" do + rel = relation("r1", "blocks", :outbound, "EXT-1", "EXT-2") + + issue = %Issue{ + id: "issue-1", + identifier: "EXT-1", + title: "Blocker issue", + description: "This issue blocks another", + comments: [], + relations: [rel], + inverse_relations: [] + } + + output = capture_io(fn -> Display.show(issue, %{full: true}) end) + + assert output =~ "Blocks:" + assert output =~ "EXT-2" + end + + test "full issue view omits relations block when issue has none" do + issue = %Issue{ + id: "issue-1", + identifier: "EXT-1", + title: "Standalone issue", + description: "No relations", + comments: [], + relations: [], + inverse_relations: [] + } + + output = capture_io(fn -> Display.show(issue, %{full: true}) end) + + refute output =~ "Blocks:" + refute output =~ "Related to:" + end + + test "relations JSON output includes direction and type fields" do + rels = [relation("r1", "blocks", :outbound, "EXT-1", "EXT-2")] + output = capture_io(fn -> Display.show(rels, %{output: "json"}) end) + + decoded = Jason.decode!(output) + assert is_list(decoded) + [entry] = decoded + assert entry["type"] == "blocks" + assert entry["direction"] == "outbound" + assert entry["id"] == "r1" + end + test "compact listing omits label brackets when issue has no labels even if labels opt is true" do issue = %Issue{ id: "issue-6", diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index 13aedf8..7056cce 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -4042,4 +4042,120 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert output =~ "No issue IDs provided!" end end + + describe "issue relation list" do + defp 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" => "u" + }, + "relatedIssue" => %{ + "id" => "i-rel", + "identifier" => rel_ident, + "title" => "#{rel_ident} title", + "url" => "u" + } + } + end + + defp relation_edge(node), do: %{"node" => node, "cursor" => "c-#{node["id"]}"} + + defp relations_stub(out_edges, inv_edges) do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + + is_inverse = String.contains?(body, "inverseRelations") + + data = + if is_inverse do + %{ + "data" => %{ + "issue" => %{ + "inverseRelations" => %{ + "edges" => inv_edges, + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + } + } + } + else + %{ + "data" => %{ + "issue" => %{ + "relations" => %{ + "edges" => out_edges, + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + } + } + } + end + + Req.Test.json(conn, data) + end) + end + + test "displays Blocks section for outbound blocks relations" do + relations_stub( + [relation_edge(relation_node("r1", "blocks", "EXT-1", "EXT-2"))], + [] + ) + + output = + capture_io(fn -> + Commands.issue_relation_list(%{args: %{issue_id: "EXT-1"}, options: %{output: "text"}}) + end) + + assert output =~ "Blocks:" + assert output =~ "EXT-2" + end + + test "displays Blocked by section for inbound blocks relations" do + relations_stub( + [], + [relation_edge(relation_node("r1", "blocks", "EXT-3", "EXT-1"))] + ) + + output = + capture_io(fn -> + Commands.issue_relation_list(%{args: %{issue_id: "EXT-1"}, options: %{output: "text"}}) + end) + + assert output =~ "Blocked by:" + assert output =~ "EXT-3" + end + + test "returns empty output when issue has no relations" do + relations_stub([], []) + + output = + capture_io(fn -> + Commands.issue_relation_list(%{args: %{issue_id: "EXT-1"}, options: %{output: "text"}}) + end) + + assert String.trim(output) == "" + end + + test "JSON output includes all relation fields" do + relations_stub( + [relation_edge(relation_node("r1", "blocks", "EXT-1", "EXT-2"))], + [] + ) + + output = + capture_io(fn -> + Commands.issue_relation_list(%{args: %{issue_id: "EXT-1"}, options: %{output: "json"}}) + end) + + [entry] = Jason.decode!(output) + assert entry["id"] == "r1" + assert entry["type"] == "blocks" + assert entry["direction"] == "outbound" + end + end end diff --git a/app/test/linear_cli/linear/issue_relation_test.exs b/app/test/linear_cli/linear/issue_relation_test.exs new file mode 100644 index 0000000..f91d017 --- /dev/null +++ b/app/test/linear_cli/linear/issue_relation_test.exs @@ -0,0 +1,201 @@ +defmodule LinearCli.Linear.IssueRelationTest do + use ExUnit.Case, async: true + + alias LinearCli.Linear + alias LinearCli.Linear.IssueRelation + + defp relation_node(id, type, src_id, src_ident, rel_id, rel_ident) do + %{ + "id" => id, + "type" => type, + "issue" => %{ + "id" => src_id, + "identifier" => src_ident, + "title" => "#{src_ident} title", + "url" => "https://example.com/#{src_ident}" + }, + "relatedIssue" => %{ + "id" => rel_id, + "identifier" => rel_ident, + "title" => "#{rel_ident} title", + "url" => "https://example.com/#{rel_ident}" + } + } + end + + defp edge(node), do: %{"node" => node, "cursor" => "cursor-#{node["id"]}"} + + defp connection(edges, has_next \\ false, end_cursor \\ nil) do + %{ + "edges" => edges, + "pageInfo" => %{"hasNextPage" => has_next, "endCursor" => end_cursor} + } + end + + defp issue_response(relations_edges, inverse_edges) do + %{ + "data" => %{ + "issue" => %{ + "relations" => connection(relations_edges), + "inverseRelations" => connection(inverse_edges) + } + } + } + end + + test "issue_relations/1 fetches outbound relations and tags them :outbound" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + + edges = + if String.contains?(body, "inverseRelations"), + do: [], + else: [edge(relation_node("rel-1", "blocks", "i1", "EXT-1", "i2", "EXT-2"))] + + Req.Test.json(conn, issue_response(edges, [])) + end) + + assert {:ok, relations} = Linear.issue_relations("i1") + outbound = Enum.filter(relations, &(&1.direction == :outbound)) + assert length(outbound) == 1 + [rel] = outbound + assert rel.id == "rel-1" + assert rel.type == "blocks" + assert rel.direction == :outbound + end + + test "issue_relations/1 returns both outbound and inbound relations" 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) + + is_inverse = String.contains?(body, "inverseRelations") + + relations_edges = + if is_inverse, + do: [], + else: [edge(relation_node("rel-1", "blocks", "i1", "EXT-1", "i2", "EXT-2"))] + + inverse_edges = + if is_inverse, + do: [edge(relation_node("rel-2", "blocks", "i3", "EXT-3", "i1", "EXT-1"))], + else: [] + + Req.Test.json(conn, issue_response(relations_edges, inverse_edges)) + end) + + assert {:ok, relations} = Linear.issue_relations("i1") + assert :counters.get(call_count, 1) == 2 + + outbound = Enum.filter(relations, &(&1.direction == :outbound)) + inbound = Enum.filter(relations, &(&1.direction == :inbound)) + + assert length(outbound) == 1 + assert length(inbound) == 1 + + [out] = outbound + assert out.id == "rel-1" + assert out.type == "blocks" + assert out.issue.identifier == "EXT-1" + assert out.related_issue.identifier == "EXT-2" + + [inv] = inbound + assert inv.id == "rel-2" + assert inv.type == "blocks" + assert inv.direction == :inbound + assert inv.issue.identifier == "EXT-3" + assert inv.related_issue.identifier == "EXT-1" + end + + test "issue_relations/1 returns empty list when issue has no relations" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, issue_response([], [])) + end) + + assert {:ok, []} = Linear.issue_relations("i1") + end + + test "issue_relations/1 returns error when issue is not found" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, %{"data" => %{"issue" => nil}}) + end) + + assert {:error, _} = Linear.issue_relations("nonexistent") + end + + test "issue_relations/1 propagates API errors" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, %{"errors" => [%{"message" => "Unauthorized"}]}) + end) + + assert {:error, _} = Linear.issue_relations("i1") + end + + test "issue_relations/1 paginates outbound relations across pages" 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) + + is_inverse = String.contains?(body, "inverseRelations") + %{"variables" => vars} = Jason.decode!(body) + after_cursor = vars["after"] + + if is_inverse do + Req.Test.json(conn, %{ + "data" => %{ + "issue" => %{ + "inverseRelations" => connection([]) + } + } + }) + else + {edges, has_next, cursor} = + if is_nil(after_cursor) do + {[edge(relation_node("rel-1", "related", "i1", "EXT-1", "i2", "EXT-2"))], true, + "cursor-1"} + else + {[edge(relation_node("rel-2", "related", "i1", "EXT-1", "i3", "EXT-3"))], false, nil} + end + + Req.Test.json(conn, %{ + "data" => %{ + "issue" => %{ + "relations" => connection(edges, has_next, cursor) + } + } + }) + end + end) + + assert {:ok, relations} = Linear.issue_relations("i1") + outbound = Enum.filter(relations, &(&1.direction == :outbound)) + assert length(outbound) == 2 + assert Enum.map(outbound, & &1.id) == ["rel-1", "rel-2"] + end + + test "IssueRelation.from_map/2 decodes outbound relation correctly" do + map = relation_node("r1", "duplicate", "i1", "EXT-1", "i2", "EXT-2") + rel = IssueRelation.from_map(map, :outbound) + + assert rel.id == "r1" + assert rel.type == "duplicate" + assert rel.direction == :outbound + assert rel.issue.identifier == "EXT-1" + assert rel.related_issue.identifier == "EXT-2" + end + + test "IssueRelation.from_map/2 decodes inbound relation correctly" do + map = relation_node("r2", "blocks", "i3", "EXT-3", "i1", "EXT-1") + rel = IssueRelation.from_map(map, :inbound) + + assert rel.id == "r2" + assert rel.type == "blocks" + assert rel.direction == :inbound + assert rel.issue.identifier == "EXT-3" + assert rel.related_issue.identifier == "EXT-1" + end +end diff --git a/documents/ash-domain-erd.adoc b/documents/ash-domain-erd.adoc index 9cf1f69..db63a56 100644 --- a/documents/ash-domain-erd.adoc +++ b/documents/ash-domain-erd.adoc @@ -73,6 +73,15 @@ erDiagram Team team Comment[] comments Label[] labels + IssueRelation[] relations + IssueRelation[] inverse_relations + } + IssueRelation { + string id PK + atom direction + string type + term issue + term related_issue } Label { string id PK @@ -105,6 +114,8 @@ erDiagram Issue }o--|| Team : "team [nested]" Issue ||--o{ Comment : "comments [nested]" Issue ||--o{ Label : "labels [nested]" + Issue ||--o{ IssueRelation : "relations [nested, outbound]" + Issue ||--o{ IssueRelation : "inverse_relations [nested, inbound]" User }o--o{ Team : "teams [nested]" Comment }o--|| User : "user/author [nested]" Project }o--o{ Team : "teams [nested]" @@ -121,7 +132,7 @@ arguments only. The table below is the plain-text equivalent of the entity boxes in the diagram above, included for accessibility. -Eight resources are registered in `LinearCli.Linear` +Nine resources are registered in `LinearCli.Linear` (`app/lib/linear_cli/linear.ex`). [cols="1,2,3", options="header"] @@ -142,7 +153,7 @@ Eight resources are registered in `LinearCli.Linear` | `LinearCli.Linear.Issue` | `id` (`:string`) -| `identifier`, `title`, `branch_name`, `description`, `assignee` (`:term`), `state` (`:term`), `team` (`:term`), `comments` (`{:array, :term}`), `labels` (`{:array, :term}`) +| `identifier`, `title`, `branch_name`, `description`, `assignee` (`:term`), `state` (`:term`), `team` (`:term`), `comments` (`{:array, :term}`), `labels` (`{:array, :term}`), `relations` (`{:array, :term}`), `inverse_relations` (`{:array, :term}`) | `LinearCli.Linear.Label` | `id` (`:string`) @@ -159,6 +170,10 @@ Eight resources are registered in `LinearCli.Linear` | `LinearCli.Linear.ProjectUpdate` | `id` (`:string`) | `body`, `health`, `url` + +| `LinearCli.Linear.IssueRelation` +| `id` (`:string`) +| `type` (`:string`), `direction` (`:atom`, `:outbound` or `:inbound`), `issue` (`:term`), `related_issue` (`:term`) |=== == Associations (text reference) @@ -204,6 +219,18 @@ data, not from declared Ash relationships. | `labels` (`{:array, :term}`) | GraphQL/nested-data — populated by `Issue.from_map/1` from `issue.labels.nodes` (full fragment only) +| `Issue` +| `IssueRelation` +| one-to-many (outbound) +| `relations` (`{:array, :term}`) +| GraphQL/nested-data — populated by `Issue.from_map/1` from `issue.relations.edges` (full fragment only); direction set to `:outbound` + +| `Issue` +| `IssueRelation` +| one-to-many (inbound) +| `inverse_relations` (`{:array, :term}`) +| GraphQL/nested-data — populated by `Issue.from_map/1` from `issue.inverseRelations.edges` (full fragment only); direction set to `:inbound` + | `User` | `Team` | many-to-many @@ -365,6 +392,13 @@ manual-implementation module, and the Linear GraphQL operation it calls. | `Linear.Issue.Update.SetStatus` | `issueUpdate(id:, input: { stateId })` via `Issue.Update.run/2` +| `IssueRelation` +| `issue_relations` +| `:list` +| read +| `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 + | `Label` | `labels_by_names` | `:by_names`