From daf6fb2ba295117737892c399de1b646dfddc443 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 12 Sep 2026 15:01:52 -0400 Subject: [PATCH 1/4] feat(issues): add --graph flag to issue view for transitive dependency graph Adds `lc issue view ISSUE --graph` which traverses blocks relations in both directions via BFS and renders an ASCII dependency diagram, an Issues table with status and (root) marker, and an Edges table. `--output json` emits `{ root, nodes, edges }` instead. `--graph --web` together fail with a usage error (exit 22). IssueRelation.endpoint_fields now includes `state { name }` so graph nodes carry workflow status. The BFS visitor uses a MapSet to handle cycles and shared dependencies safely; partial API failures abort with the failing issue identifier. Closes EXT-56 Co-Authored-By: Claude Sonnet 4.6 --- app/lib/linear_cli/cli.ex | 7 +- .../linear_cli/cli/commands/issues/graph.ex | 124 +++++ .../linear_cli/cli/commands/issues/read.ex | 37 +- app/lib/linear_cli/cli/display.ex | 194 +++++++ app/lib/linear_cli/linear/issue_relation.ex | 12 +- .../cli/commands/issues/graph_test.exs | 508 ++++++++++++++++++ documents/ash-domain-erd.adoc | 32 +- 7 files changed, 901 insertions(+), 13 deletions(-) create mode 100644 app/lib/linear_cli/cli/commands/issues/graph.ex create mode 100644 app/test/linear_cli/cli/commands/issues/graph_test.exs diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 27fb222..0d58aa8 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -750,7 +750,12 @@ defmodule LinearCli.CLI do issue_id: [value_name: "ISSUE_ID", help: "The Issue (i.e. CRY-1)", required: true] ], flags: [ - web: [short: "-w", long: "--web", help: "Open the issue in your browser"] + web: [short: "-w", long: "--web", help: "Open the issue in your browser"], + graph: [ + long: "--graph", + help: + "Show the transitive dependency graph (blocks relations) rooted at this issue" + ] ] ], assign: [ diff --git a/app/lib/linear_cli/cli/commands/issues/graph.ex b/app/lib/linear_cli/cli/commands/issues/graph.ex new file mode 100644 index 0000000..a463e11 --- /dev/null +++ b/app/lib/linear_cli/cli/commands/issues/graph.ex @@ -0,0 +1,124 @@ +defmodule LinearCli.CLI.Commands.Issues.Graph do + @moduledoc """ + Builds a transitive dependency graph rooted at a single issue. + + Follows only `blocks` relations in both directions (outbound = this issue + blocks others; inbound = others block this issue). Traversal is BFS, + visiting each issue identifier at most once, so cycles and shared + dependencies terminate safely. + + Returns a plain map ready for display or JSON encoding: + + %{ + root: "EXT-56", + nodes: [%{identifier: "EXT-56", status: "In Progress", title: "..."}, ...], + edges: [%{source: "EXT-40", target: "EXT-56"}, ...] + } + + `nodes` is sorted by identifier; `edges` by (source, target). + """ + + alias LinearCli.Linear + + @max_nodes 100 + + @doc """ + Builds the transitive dependency graph rooted at `root_identifier`. + + `root_issue` is the already-fetched `%LinearCli.Linear.Issue{}` for the root, + used to populate the root node's title and status without an extra API call. + + Returns `{:ok, graph}` or `{:error, {issue_id, reason}}` where the + error identifies which issue's relations could not be fetched. + """ + @spec build(String.t(), struct()) :: + {:ok, %{root: String.t(), nodes: list(map()), edges: list(map())}} + | {:error, {String.t(), term()}} + def build(root_identifier, root_issue) do + root_status = (root_issue.state && root_issue.state.name) || "" + root_title = root_issue.title || "" + + initial_nodes = %{ + root_identifier => %{ + identifier: root_identifier, + status: root_status, + title: root_title + } + } + + case bfs([root_identifier], MapSet.new(), initial_nodes, []) do + {:ok, nodes_map, edges} -> + sorted_nodes = + nodes_map + |> Map.values() + |> Enum.sort_by(& &1.identifier) + + sorted_edges = + edges + |> Enum.uniq_by(fn %{source: s, target: t} -> {s, t} end) + |> Enum.sort_by(fn %{source: s, target: t} -> {s, t} end) + + {:ok, %{root: root_identifier, nodes: sorted_nodes, edges: sorted_edges}} + + {:error, _} = err -> + err + end + end + + # BFS: queue is a list of identifiers to visit; visited is a MapSet of + # identifiers already processed; nodes_map maps identifier -> node info; + # edges is an accumulator list. + defp bfs([], _visited, nodes_map, edges), do: {:ok, nodes_map, edges} + + defp bfs([id | rest], visited, nodes_map, edges) do + if MapSet.member?(visited, id) or map_size(nodes_map) >= @max_nodes do + bfs(rest, visited, nodes_map, edges) + else + visited = MapSet.put(visited, id) + + case Linear.issue_relations(id) do + {:error, reason} -> + {:error, {id, reason}} + + {:ok, relations} -> + blocks_only = Enum.filter(relations, &(&1.type == "blocks")) + + {new_nodes_map, new_edges, new_queue} = + Enum.reduce(blocks_only, {nodes_map, edges, rest}, fn rel, acc -> + process_relation(rel, acc, visited) + end) + + bfs(new_queue, visited, new_nodes_map, new_edges) + end + end + end + + defp process_relation(rel, {nm, ed, q}, visited) do + {nm, ed, q} = add_endpoint(rel.issue, nm, ed, q, visited) + {nm, ed, q} = add_endpoint(rel.related_issue, nm, ed, q, visited) + source = rel.issue && rel.issue.identifier + target = rel.related_issue && rel.related_issue.identifier + ed = if source && target, do: [%{source: source, target: target} | ed], else: ed + {nm, ed, q} + end + + defp add_endpoint(nil, nodes_map, edges, queue, _visited), do: {nodes_map, edges, queue} + + defp add_endpoint(endpoint, nodes_map, edges, queue, visited) do + id = endpoint.identifier + + nodes_map = + Map.put_new(nodes_map, id, %{ + identifier: id, + status: get_in(endpoint, [:state, :name]) || "", + title: endpoint.title || "" + }) + + queue = + if MapSet.member?(visited, id) or id in queue, + do: queue, + else: queue ++ [id] + + {nodes_map, edges, queue} + end +end diff --git a/app/lib/linear_cli/cli/commands/issues/read.ex b/app/lib/linear_cli/cli/commands/issues/read.ex index b68febe..c6c2103 100644 --- a/app/lib/linear_cli/cli/commands/issues/read.ex +++ b/app/lib/linear_cli/cli/commands/issues/read.ex @@ -6,6 +6,7 @@ defmodule LinearCli.CLI.Commands.Issues.Read do """ alias LinearCli.Browser + alias LinearCli.CLI.Commands.Issues.Graph alias LinearCli.CLI.{Display, Projects} alias LinearCli.CLI.Issue.Identifiers alias LinearCli.{Linear, Profiles} @@ -72,15 +73,33 @@ defmodule LinearCli.CLI.Commands.Issues.Read do def issue_view(result, opts \\ []) def issue_view(%{args: %{issue_id: issue_id}, flags: flags, options: options}, opts) do - expanded_id = Identifiers.expand_issue_id(issue_id) - - with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}) do - if flags.web do - Browser.open_url(issue.url, opts) - else - Display.show(issue, %{output: options.output, full: true}) - :ok - end + graph? = Map.get(flags, :graph, false) + web? = flags.web + + cond do + graph? && web? -> + {:error, {:smells_bad, "--graph and --web cannot be used together"}} + + graph? -> + expanded_id = Identifiers.expand_issue_id(issue_id) + + with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}), + {:ok, graph} <- Graph.build(expanded_id, issue) do + Display.show_graph(graph, %{output: options.output}) + :ok + end + + true -> + expanded_id = Identifiers.expand_issue_id(issue_id) + + with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}) do + if web? do + Browser.open_url(issue.url, opts) + else + Display.show(issue, %{output: options.output, full: true}) + :ok + end + end end end diff --git a/app/lib/linear_cli/cli/display.ex b/app/lib/linear_cli/cli/display.ex index a1b7a0f..9e5042f 100644 --- a/app/lib/linear_cli/cli/display.ex +++ b/app/lib/linear_cli/cli/display.ex @@ -30,6 +30,21 @@ defmodule LinearCli.CLI.Display do end end + @doc """ + Prints a dependency graph produced by `LinearCli.CLI.Commands.Issues.Graph.build/1`. + + With `--output json` emits only the structured graph object. Text output renders + a diagram followed by Issues and Edges tables. + """ + def show_graph(graph, opts \\ %{}) do + if Map.get(opts, :output, "text") == "json" do + graph |> graph_to_plain() |> Jason.encode!(pretty: true) |> IO.puts() + else + text = graph_text(graph) + Pager.maybe_page(text, opts) + end + end + defp format_text([%IssueRelation{} | _] = relations, _opts), do: relations_block(relations) defp format_text(subject, opts) do @@ -188,6 +203,185 @@ defmodule LinearCli.CLI.Display do @doc "Returns a plain-map representation of an IssueRelation suitable for JSON encoding." def relation_to_plain(%IssueRelation{} = relation), do: to_plain(relation) + # --- Dependency graph rendering --- + + defp graph_text(%{root: root, nodes: nodes, edges: edges}) do + diagram = graph_diagram(root, nodes, edges) + issues_table = graph_issues_table(root, nodes) + edges_table = graph_edges_table(edges) + + [ + "Dependency graph", + "A -> B means A blocks B", + "", + diagram, + "", + issues_table, + "", + edges_table + ] + |> Enum.join("\n") + end + + defp graph_to_plain(%{root: root, nodes: nodes, edges: edges}) do + %{ + "root" => root, + "nodes" => + Enum.map(nodes, fn n -> + %{"identifier" => n.identifier, "status" => n.status, "title" => n.title} + end), + "edges" => Enum.map(edges, fn e -> %{"source" => e.source, "target" => e.target} end) + } + end + + defp graph_diagram(root, nodes, edges) do + by_id = Map.new(nodes, &{&1.identifier, &1}) + + out_adj = + Enum.reduce(edges, %{}, fn e, acc -> + Map.update(acc, e.source, [e.target], &Enum.sort([e.target | &1])) + end) + + in_adj = + Enum.reduce(edges, %{}, fn e, acc -> + Map.update(acc, e.target, [e.source], &Enum.sort([e.source | &1])) + end) + + render_diagram(root, by_id, out_adj, in_adj) + end + + defp node_label(%{identifier: id, status: s}) when is_binary(s) and s != "" do + "#{id} [#{s}]" + end + + defp node_label(%{identifier: id}), do: id + + # Renders a compact left-to-right diagram. + # Predecessors of root appear on the left, root in the middle, + # successors (and their successors) on the right. + defp render_diagram(root, by_id, out_adj, in_adj) do + root_node = Map.get(by_id, root, %{identifier: root, status: "", title: ""}) + root_label = node_label(root_node) + + pred_ids = Map.get(in_adj, root, []) + succ_ids = Map.get(out_adj, root, []) + + pred_labels = + Enum.map(pred_ids, fn id -> + node_label(Map.get(by_id, id, %{identifier: id, status: ""})) + end) + + succ_labels = + Enum.map(succ_ids, fn id -> + node_label(Map.get(by_id, id, %{identifier: id, status: ""})) + end) + + # For each successor, gather their successors for chaining + succ_succ_map = + Map.new(succ_ids, fn id -> + ids = Map.get(out_adj, id, []) + + labels = + Enum.map(ids, fn sid -> + node_label(Map.get(by_id, sid, %{identifier: sid, status: ""})) + end) + + {id, labels} + end) + + render_columns(pred_labels, root_label, succ_labels, succ_ids, succ_succ_map) + end + + defp render_columns([], root_label, [], _succ_ids, _succ_succ_map) do + root_label + end + + defp render_columns([], root_label, [single_succ], succ_ids, succ_succ_map) do + succ_id = List.first(succ_ids) + ss_labels = Map.get(succ_succ_map, succ_id, []) + + case ss_labels do + [] -> + "#{root_label} --> #{single_succ}" + + [one] -> + "#{root_label} --> #{single_succ} --> #{one}" + + many -> + first = "#{root_label} --> #{single_succ} --+--> #{List.first(many)}" + pad = String.duplicate(" ", String.length(root_label) + String.length(single_succ) + 9) + rest = Enum.map(Enum.drop(many, 1), &"#{pad}+--> #{&1}") + Enum.join([first | rest], "\n") + end + end + + defp render_columns([], root_label, succs, _succ_ids, _succ_succ_map) do + first_line = "#{root_label} --+--> #{List.first(succs)}" + pad = String.duplicate(" ", String.length(root_label) + 1) + rest = Enum.map(Enum.drop(succs, 1), &"#{pad} +--> #{&1}") + Enum.join([first_line | rest], "\n") + end + + defp render_columns(preds, root_label, succs, _succ_ids, _succ_succ_map) do + pred_max = Enum.max(Enum.map(preds, &String.length/1)) + mid = div(length(preds), 2) + + succ_arrow = + case succs do + [] -> + root_label + + [s] -> + "#{root_label} --> #{s}" + + [h | t] -> + pad = String.duplicate(" ", String.length(root_label) + 1) + first = "#{root_label} --+--> #{h}" + rest = Enum.map(t, &"#{pad} +--> #{&1}") + Enum.join([first | rest], "\n") + end + + preds + |> Enum.with_index() + |> Enum.map_join("\n", fn {pl, i} -> + padded = String.pad_trailing(pl, pred_max) + + if i == mid do + "#{padded} --+--> #{succ_arrow}" + else + "#{padded} --+" + end + end) + end + + defp graph_issues_table(root, nodes) do + id_w = nodes |> Enum.map(&String.length(&1.identifier)) |> Enum.max(fn -> 5 end) |> max(5) + st_w = nodes |> Enum.map(&String.length(&1.status)) |> Enum.max(fn -> 6 end) |> max(6) + + header = + "#{String.pad_trailing("ISSUE", id_w)} #{String.pad_trailing("STATUS", st_w)} TITLE" + + rows = + Enum.map(nodes, fn n -> + title = if n.identifier == root, do: "#{n.title} (root)", else: n.title + + "#{String.pad_trailing(n.identifier, id_w)} #{String.pad_trailing(n.status, st_w)} #{title}" + end) + + Enum.join([header | rows], "\n") + end + + defp graph_edges_table([]) do + "SOURCE TARGET\n(none)" + end + + defp graph_edges_table(edges) do + src_w = edges |> Enum.map(&String.length(&1.source)) |> Enum.max() |> max(6) + header = "#{String.pad_trailing("SOURCE", src_w)} TARGET" + rows = Enum.map(edges, fn e -> "#{String.pad_trailing(e.source, src_w)} #{e.target}" end) + Enum.join([header | rows], "\n") + 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/issue_relation.ex b/app/lib/linear_cli/linear/issue_relation.ex index 13fafbe..753aaa2 100644 --- a/app/lib/linear_cli/linear/issue_relation.ex +++ b/app/lib/linear_cli/linear/issue_relation.ex @@ -38,7 +38,7 @@ defmodule LinearCli.Linear.IssueRelation do attribute :related_issue, :term, public?: true end - @endpoint_fields "id identifier title url" + @endpoint_fields "id identifier title url state { name }" @doc "GraphQL field selection for each issue endpoint inside a relation node." def endpoint_fields, do: @endpoint_fields @@ -64,7 +64,15 @@ defmodule LinearCli.Linear.IssueRelation do 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"]} + state = map["state"] && %{name: map["state"]["name"]} + + %{ + id: map["id"], + identifier: map["identifier"], + title: map["title"], + url: map["url"], + state: state + } end end diff --git a/app/test/linear_cli/cli/commands/issues/graph_test.exs b/app/test/linear_cli/cli/commands/issues/graph_test.exs new file mode 100644 index 0000000..af5e6ea --- /dev/null +++ b/app/test/linear_cli/cli/commands/issues/graph_test.exs @@ -0,0 +1,508 @@ +defmodule LinearCli.CLI.Commands.Issues.GraphTest do + use ExUnit.Case, async: true + import ExUnit.CaptureIO + import LinearCli.CLI.IssueCommandsHelpers + + alias LinearCli.CLI.Commands.Issues.Graph + alias LinearCli.CLI.Display + + # --- Helpers --- + + defp rel_node(id, type, src_ident, src_state, rel_ident, rel_state) do + %{ + "id" => id, + "type" => type, + "issue" => endpoint_map(src_ident, src_state), + "relatedIssue" => endpoint_map(rel_ident, rel_state) + } + end + + defp endpoint_map(identifier, state_name) do + state = if state_name, do: %{"name" => state_name}, else: nil + + %{ + "id" => "id-#{identifier}", + "identifier" => identifier, + "title" => "Title of #{identifier}", + "url" => "https://example.com/#{identifier}", + "state" => state + } + end + + defp rel_edge(node), do: %{"node" => node, "cursor" => "c-#{node["id"]}"} + + defp page_info(has_next \\ false), + do: %{"hasNextPage" => has_next, "endCursor" => nil} + + defp relations_response(out_edges, inv_edges) do + fn body -> + is_inverse = String.contains?(body, "inverseRelations") + + if is_inverse do + %{ + "data" => %{ + "issue" => %{ + "inverseRelations" => %{"edges" => inv_edges, "pageInfo" => page_info()} + } + } + } + else + %{ + "data" => %{ + "issue" => %{ + "relations" => %{"edges" => out_edges, "pageInfo" => page_info()} + } + } + } + end + end + end + + # Stubs one or more issue-relation calls, dispatching by issueId variable. + # `by_id` is a map of identifier -> {out_edges, inv_edges}. + defp stub_relations(by_id) do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + issue_id = decoded["variables"]["issueId"] + {out, inv} = Map.fetch!(by_id, issue_id) + response_fn = relations_response(out, inv) + Req.Test.json(conn, response_fn.(body)) + end) + end + + defp root_issue(identifier, state_name \\ "Triage") do + state = if state_name, do: %{name: state_name}, else: nil + + %{ + id: "id-#{identifier}", + identifier: identifier, + title: "Title of #{identifier}", + state: state + } + end + + # --- Tests --- + + describe "Graph.build/2" do + test "returns just the root node when issue has no blocks relations" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + Req.Test.json(conn, relations_response([], []).(body)) + end) + + assert {:ok, graph} = Graph.build("EXT-56", root_issue("EXT-56", "Triage")) + + assert graph.root == "EXT-56" + assert length(graph.nodes) == 1 + assert [%{identifier: "EXT-56", status: "Triage"}] = graph.nodes + assert graph.edges == [] + end + + test "non-blocks relations are excluded" do + related_edge = rel_edge(rel_node("r1", "related", "EXT-56", nil, "EXT-99", nil)) + duplicate_edge = rel_edge(rel_node("r2", "duplicate", "EXT-56", nil, "EXT-88", nil)) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + Req.Test.json(conn, relations_response([related_edge, duplicate_edge], []).(body)) + end) + + assert {:ok, graph} = Graph.build("EXT-56", root_issue("EXT-56")) + + # Only the root - related and duplicate are ignored + assert length(graph.nodes) == 1 + assert graph.edges == [] + end + + test "single outbound blocks edge (root blocks EXT-57)" do + # outbound: EXT-56 -> EXT-57 + out_edge = rel_edge(rel_node("r1", "blocks", "EXT-56", "In Progress", "EXT-57", "Todo")) + + stub_relations(%{ + "EXT-56" => {[out_edge], []}, + "EXT-57" => {[], []} + }) + + assert {:ok, graph} = Graph.build("EXT-56", root_issue("EXT-56", "In Progress")) + + ids = Enum.map(graph.nodes, & &1.identifier) + assert "EXT-56" in ids + assert "EXT-57" in ids + assert graph.edges == [%{source: "EXT-56", target: "EXT-57"}] + end + + test "single inbound blocks edge (EXT-40 blocks root)" do + # inbound: EXT-40 -> EXT-56 + inv_edge = rel_edge(rel_node("r1", "blocks", "EXT-40", "Done", "EXT-56", "Triage")) + + stub_relations(%{ + "EXT-56" => {[], [inv_edge]}, + "EXT-40" => {[], []} + }) + + assert {:ok, graph} = Graph.build("EXT-56", root_issue("EXT-56", "Triage")) + + ids = Enum.map(graph.nodes, & &1.identifier) + assert "EXT-40" in ids + assert "EXT-56" in ids + assert graph.edges == [%{source: "EXT-40", target: "EXT-56"}] + end + + test "transitive chain: EXT-40 -> EXT-56 -> EXT-57" do + inv_edge = rel_edge(rel_node("r1", "blocks", "EXT-40", "Done", "EXT-56", "Triage")) + out_edge = rel_edge(rel_node("r2", "blocks", "EXT-56", "Triage", "EXT-57", "Todo")) + + stub_relations(%{ + "EXT-56" => {[out_edge], [inv_edge]}, + "EXT-40" => {[], []}, + "EXT-57" => {[], []} + }) + + assert {:ok, graph} = Graph.build("EXT-56", root_issue("EXT-56", "Triage")) + + ids = Enum.map(graph.nodes, & &1.identifier) + assert "EXT-40" in ids + assert "EXT-56" in ids + assert "EXT-57" in ids + + assert %{source: "EXT-40", target: "EXT-56"} in graph.edges + assert %{source: "EXT-56", target: "EXT-57"} in graph.edges + end + + test "branching: root blocks two issues" do + out_a = rel_edge(rel_node("r1", "blocks", "EXT-56", "Triage", "EXT-57", "Todo")) + out_b = rel_edge(rel_node("r2", "blocks", "EXT-56", "Triage", "EXT-58", "Todo")) + + stub_relations(%{ + "EXT-56" => {[out_a, out_b], []}, + "EXT-57" => {[], []}, + "EXT-58" => {[], []} + }) + + assert {:ok, graph} = Graph.build("EXT-56", root_issue("EXT-56", "Triage")) + + ids = Enum.map(graph.nodes, & &1.identifier) |> Enum.sort() + assert ids == ["EXT-56", "EXT-57", "EXT-58"] + assert length(graph.edges) == 2 + end + + test "shared dependency (diamond): two issues block root, root blocks one" do + inv_a = rel_edge(rel_node("r1", "blocks", "EXT-40", "Done", "EXT-56", "Triage")) + inv_b = rel_edge(rel_node("r2", "blocks", "EXT-55", "In Progress", "EXT-56", "Triage")) + out_c = rel_edge(rel_node("r3", "blocks", "EXT-56", "Triage", "EXT-57", "Todo")) + + stub_relations(%{ + "EXT-56" => {[out_c], [inv_a, inv_b]}, + "EXT-40" => {[], []}, + "EXT-55" => {[], []}, + "EXT-57" => {[], []} + }) + + assert {:ok, graph} = Graph.build("EXT-56", root_issue("EXT-56", "Triage")) + + ids = Enum.map(graph.nodes, & &1.identifier) |> Enum.sort() + assert ids == ["EXT-40", "EXT-55", "EXT-56", "EXT-57"] + assert length(graph.edges) == 3 + end + + test "cycle: A blocks B, B blocks A" do + # EXT-56 has an outbound edge to EXT-99 + out = rel_edge(rel_node("r1", "blocks", "EXT-56", "Triage", "EXT-99", "Todo")) + # EXT-99 has an outbound edge back to EXT-56 (cycle) + out_back = rel_edge(rel_node("r2", "blocks", "EXT-99", "Todo", "EXT-56", "Triage")) + + stub_relations(%{ + "EXT-56" => {[out], []}, + "EXT-99" => {[out_back], []} + }) + + assert {:ok, graph} = Graph.build("EXT-56", root_issue("EXT-56", "Triage")) + + # Should terminate finitely with each node once + ids = Enum.map(graph.nodes, & &1.identifier) |> Enum.sort() + assert ids == ["EXT-56", "EXT-99"] + end + + test "nodes are sorted by identifier" do + out_a = rel_edge(rel_node("r1", "blocks", "EXT-56", "Triage", "EXT-99", "Todo")) + out_b = rel_edge(rel_node("r2", "blocks", "EXT-56", "Triage", "EXT-10", "Todo")) + + stub_relations(%{ + "EXT-56" => {[out_a, out_b], []}, + "EXT-99" => {[], []}, + "EXT-10" => {[], []} + }) + + assert {:ok, graph} = Graph.build("EXT-56", root_issue("EXT-56")) + + ids = Enum.map(graph.nodes, & &1.identifier) + assert ids == Enum.sort(ids) + end + + test "edges are sorted by (source, target)" do + out_b = rel_edge(rel_node("r1", "blocks", "EXT-56", "Triage", "EXT-99", "Todo")) + out_a = rel_edge(rel_node("r2", "blocks", "EXT-56", "Triage", "EXT-10", "Todo")) + + stub_relations(%{ + "EXT-56" => {[out_b, out_a], []}, + "EXT-99" => {[], []}, + "EXT-10" => {[], []} + }) + + assert {:ok, graph} = Graph.build("EXT-56", root_issue("EXT-56")) + + pairs = Enum.map(graph.edges, fn e -> {e.source, e.target} end) + assert pairs == Enum.sort(pairs) + end + + test "error from issue_relations is returned with the failing issue identifier" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, _body, conn} = Plug.Conn.read_body(conn) + + Req.Test.json(conn, %{ + "errors" => [%{"message" => "Unauthorized"}] + }) + end) + + assert {:error, {"EXT-56", _reason}} = Graph.build("EXT-56", root_issue("EXT-56")) + end + end + + describe "Display.show_graph/2 text output" do + test "includes legend, Issues section, and Edges section" do + graph = %{ + root: "EXT-56", + nodes: [ + %{identifier: "EXT-40", status: "Done", title: "Predecessor"}, + %{identifier: "EXT-56", status: "Triage", title: "Root issue"} + ], + edges: [%{source: "EXT-40", target: "EXT-56"}] + } + + output = capture_io(fn -> Display.show_graph(graph) end) + + assert output =~ "Dependency graph" + assert output =~ "A -> B means A blocks B" + assert output =~ "EXT-40" + assert output =~ "EXT-56" + assert output =~ "Done" + assert output =~ "Triage" + assert output =~ "(root)" + end + + test "empty graph shows root with no edges" do + graph = %{ + root: "EXT-56", + nodes: [%{identifier: "EXT-56", status: "Triage", title: "Solo issue"}], + edges: [] + } + + output = capture_io(fn -> Display.show_graph(graph) end) + + assert output =~ "EXT-56" + assert output =~ "Solo issue" + assert output =~ "(none)" + end + + test "Issues table header is ISSUE STATUS TITLE" do + graph = %{ + root: "EXT-1", + nodes: [%{identifier: "EXT-1", status: "Todo", title: "T"}], + edges: [] + } + + output = capture_io(fn -> Display.show_graph(graph) end) + + assert output =~ "ISSUE" + assert output =~ "STATUS" + assert output =~ "TITLE" + end + + test "Edges table header is SOURCE TARGET" do + graph = %{ + root: "EXT-2", + nodes: [ + %{identifier: "EXT-1", status: "Done", title: "A"}, + %{identifier: "EXT-2", status: "Todo", title: "B"} + ], + edges: [%{source: "EXT-1", target: "EXT-2"}] + } + + output = capture_io(fn -> Display.show_graph(graph) end) + + assert output =~ "SOURCE" + assert output =~ "TARGET" + assert output =~ "EXT-1" + assert output =~ "EXT-2" + end + + test "diagram direction is deterministic across runs" do + graph = %{ + root: "EXT-56", + nodes: [ + %{identifier: "EXT-40", status: "Done", title: "A"}, + %{identifier: "EXT-56", status: "Triage", title: "Root"}, + %{identifier: "EXT-57", status: "Todo", title: "C"} + ], + edges: [ + %{source: "EXT-40", target: "EXT-56"}, + %{source: "EXT-56", target: "EXT-57"} + ] + } + + output1 = capture_io(fn -> Display.show_graph(graph) end) + output2 = capture_io(fn -> Display.show_graph(graph) end) + + assert output1 == output2 + end + end + + describe "Display.show_graph/2 JSON output" do + test "emits {root, nodes, edges} object with no extra fields" do + graph = %{ + root: "EXT-56", + nodes: [ + %{identifier: "EXT-40", status: "Done", title: "Add thing"}, + %{identifier: "EXT-56", status: "Triage", title: "Root"} + ], + edges: [%{source: "EXT-40", target: "EXT-56"}] + } + + output = capture_io(fn -> Display.show_graph(graph, %{output: "json"}) end) + + decoded = Jason.decode!(output) + assert Map.keys(decoded) |> Enum.sort() == ["edges", "nodes", "root"] + assert decoded["root"] == "EXT-56" + assert length(decoded["nodes"]) == 2 + assert length(decoded["edges"]) == 1 + [edge] = decoded["edges"] + assert edge["source"] == "EXT-40" + assert edge["target"] == "EXT-56" + end + + test "JSON node has identifier, status, title keys" do + graph = %{ + root: "EXT-56", + nodes: [%{identifier: "EXT-56", status: "Triage", title: "Root"}], + edges: [] + } + + output = capture_io(fn -> Display.show_graph(graph, %{output: "json"}) end) + + decoded = Jason.decode!(output) + [node] = decoded["nodes"] + assert Map.keys(node) |> Enum.sort() == ["identifier", "status", "title"] + end + end + + describe "lc issue view --graph CLI integration" do + defp full_issue_response(identifier, state_name) do + state = %{"id" => "s1", "name" => state_name, "type" => "triage"} + %{"data" => %{"issue" => issue_map(%{"identifier" => identifier, "state" => state})}} + end + + defp stub_view_and_relations(identifier, state_name, out_edges, inv_edges) do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, full_issue_response(identifier, state_name)) + + String.contains?(body, "inverseRelations") -> + Req.Test.json( + conn, + %{ + "data" => %{ + "issue" => %{ + "inverseRelations" => %{"edges" => inv_edges, "pageInfo" => page_info()} + } + } + } + ) + + true -> + Req.Test.json( + conn, + %{ + "data" => %{ + "issue" => %{ + "relations" => %{"edges" => out_edges, "pageInfo" => page_info()} + } + } + } + ) + end + end) + end + + test "--graph flag is accepted and prints Dependency graph header" do + stub_view_and_relations("EXT-56", "Triage", [], []) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "view", "EXT-56", "--graph"]) + end) + + assert output =~ "Dependency graph" + assert output =~ "EXT-56" + end + + test "--graph and --web together return a usage error" do + output = + capture_io(:stderr, fn -> + assert catch_throw( + LinearCli.CLI.main( + ["issue", "view", "EXT-56", "--graph", "--web"], + fn code -> throw({:halted, code}) end + ) + ) == {:halted, 22} + end) + + assert output =~ "--graph" or output =~ "web" + end + + test "without --graph, issue view output is unchanged" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, _body, conn} = Plug.Conn.read_body(conn) + Req.Test.json(conn, full_issue_response("EXT-56", "In Progress")) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "view", "EXT-56"]) + end) + + assert output =~ "EXT-56" + assert output =~ "[In Progress]" + refute output =~ "Dependency graph" + end + + test "--graph --output json emits graph JSON not issue JSON" do + stub_view_and_relations("EXT-56", "Triage", [], []) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "view", + "EXT-56", + "--graph", + "--output", + "json" + ]) + end) + + decoded = Jason.decode!(output) + # Must be the graph shape, not the issue shape + assert Map.has_key?(decoded, "root") + assert Map.has_key?(decoded, "nodes") + assert Map.has_key?(decoded, "edges") + refute Map.has_key?(decoded, "identifier") + end + end +end diff --git a/documents/ash-domain-erd.adoc b/documents/ash-domain-erd.adoc index 773eac2..94a318c 100644 --- a/documents/ash-domain-erd.adoc +++ b/documents/ash-domain-erd.adoc @@ -400,7 +400,7 @@ manual-implementation module, and the Linear GraphQL operation it calls. | `: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 +| `issue(id: $issueId) { relations(first:, after:) { edges { node { ... } cursor } pageInfo { ... } } }` and same for `inverseRelations`, fetched concurrently; paginated up to 100 per direction. Each relation node's `issue` and `relatedIssue` endpoints are fetched with `IssueRelation.endpoint_fields/0` (`id identifier title url state { name }`) — the nested `state { name }` field was added to support the `--graph` dependency graph display. | `IssueRelation` | `create_issue_relation` @@ -472,6 +472,36 @@ attribute) because `Issue.full_fields/0` references `User`, `Team`, and `Comment` — cross-file module-attribute evaluation order is not guaranteed at compile time. +=== `LinearCli.Linear.IssueRelation.endpoint_fields/0` and `relation_fields/0` + +`app/lib/linear_cli/linear/issue_relation.ex` — module-level helpers that +return GraphQL field-selection strings, used everywhere a relation node is +fetched. + +`endpoint_fields/0` returns `"id identifier title url state { name }"` — +the fragment for each issue endpoint (`issue` and `relatedIssue`) inside a +relation node. The `state { name }` subfield was added to support the +`--graph` dependency graph display, which needs workflow status for each node. + +`relation_fields/0` composes `endpoint_fields/0` into the full relation +node fragment: `"id type issue { ... } relatedIssue { ... }"`. + +Both functions are evaluated at call time (not module attributes) to avoid +compile-time cross-file evaluation ordering issues. + +=== `LinearCli.CLI.Commands.Issues.Graph` + +`app/lib/linear_cli/cli/commands/issues/graph.ex` — pure BFS traversal +that builds a transitive dependency graph from a root issue identifier. + +`build/2` accepts `(root_identifier, root_issue)` where `root_issue` is the +already-fetched `Issue` struct (reusing the `issue view` fetch to avoid a +redundant API call). It traverses `blocks` relations in both directions via +`Linear.issue_relations/1`, visiting each identifier at most once (cycle-safe), +and returns `{:ok, %{root:, nodes:, edges:}}` or `{:error, {identifier, reason}}`. +Capped at 100 nodes; deterministic output (nodes sorted by identifier, edges +by source then target). + === `LinearCli.Linear.Paginate` `app/lib/linear_cli/linear/paginate.ex` — a standalone helper module. From 7c0ed6c086d82436e0207ff33fa83c6533a6b54b Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 12 Sep 2026 15:21:49 -0400 Subject: [PATCH 2/4] refactor(issue): address PR review feedback on --graph flag - Replace case/if-else blocks with function clause dispatch and with/else in Graph module (bougyman review) - Extract chain_succ/3 and succ_arrow/2 helpers to replace case in Display.show_graph rendering (bougyman review) - Split show_graph/2 into pattern-matched function heads to eliminate if-else (bougyman review) - Fix Graph.build identifier: use issue.identifier (not expanded_id) as graph root key to avoid UUID/identifier duplication (Copilot review) - Enforce @max_nodes cap in add_endpoint guard clause; only add edges when both endpoints are in the node map (Copilot review) - Fix docstring arity: Graph.build/1 -> Graph.build/2 (Copilot review) Co-Authored-By: Claude Sonnet 4.6 --- .../linear_cli/cli/commands/issues/graph.ex | 78 +++++++++++-------- .../linear_cli/cli/commands/issues/read.ex | 2 +- app/lib/linear_cli/cli/display.ex | 74 ++++++++---------- 3 files changed, 77 insertions(+), 77 deletions(-) diff --git a/app/lib/linear_cli/cli/commands/issues/graph.ex b/app/lib/linear_cli/cli/commands/issues/graph.ex index a463e11..269135d 100644 --- a/app/lib/linear_cli/cli/commands/issues/graph.ex +++ b/app/lib/linear_cli/cli/commands/issues/graph.ex @@ -46,22 +46,15 @@ defmodule LinearCli.CLI.Commands.Issues.Graph do } } - case bfs([root_identifier], MapSet.new(), initial_nodes, []) do - {:ok, nodes_map, edges} -> - sorted_nodes = - nodes_map - |> Map.values() - |> Enum.sort_by(& &1.identifier) - - sorted_edges = - edges - |> Enum.uniq_by(fn %{source: s, target: t} -> {s, t} end) - |> Enum.sort_by(fn %{source: s, target: t} -> {s, t} end) - - {:ok, %{root: root_identifier, nodes: sorted_nodes, edges: sorted_edges}} - - {:error, _} = err -> - err + with {:ok, nodes_map, edges} <- bfs([root_identifier], MapSet.new(), initial_nodes, []) do + sorted_nodes = nodes_map |> Map.values() |> Enum.sort_by(& &1.identifier) + + sorted_edges = + edges + |> Enum.uniq_by(fn %{source: s, target: t} -> {s, t} end) + |> Enum.sort_by(fn %{source: s, target: t} -> {s, t} end) + + {:ok, %{root: root_identifier, nodes: sorted_nodes, edges: sorted_edges}} end end @@ -71,26 +64,35 @@ defmodule LinearCli.CLI.Commands.Issues.Graph do defp bfs([], _visited, nodes_map, edges), do: {:ok, nodes_map, edges} defp bfs([id | rest], visited, nodes_map, edges) do - if MapSet.member?(visited, id) or map_size(nodes_map) >= @max_nodes do - bfs(rest, visited, nodes_map, edges) - else - visited = MapSet.put(visited, id) + visit( + MapSet.member?(visited, id) or map_size(nodes_map) >= @max_nodes, + id, + rest, + visited, + nodes_map, + edges + ) + end - case Linear.issue_relations(id) do - {:error, reason} -> - {:error, {id, reason}} + defp visit(true, _id, rest, visited, nodes_map, edges), do: bfs(rest, visited, nodes_map, edges) - {:ok, relations} -> - blocks_only = Enum.filter(relations, &(&1.type == "blocks")) + defp visit(false, id, rest, visited, nodes_map, edges) do + visited = MapSet.put(visited, id) + traverse(Linear.issue_relations(id), id, rest, visited, nodes_map, edges) + end - {new_nodes_map, new_edges, new_queue} = - Enum.reduce(blocks_only, {nodes_map, edges, rest}, fn rel, acc -> - process_relation(rel, acc, visited) - end) + defp traverse({:error, reason}, id, _rest, _visited, _nodes_map, _edges), + do: {:error, {id, reason}} - bfs(new_queue, visited, new_nodes_map, new_edges) - end - end + defp traverse({:ok, relations}, _id, rest, visited, nodes_map, edges) do + blocks_only = Enum.filter(relations, &(&1.type == "blocks")) + + {new_nodes_map, new_edges, new_queue} = + Enum.reduce(blocks_only, {nodes_map, edges, rest}, fn rel, acc -> + process_relation(rel, acc, visited) + end) + + bfs(new_queue, visited, new_nodes_map, new_edges) end defp process_relation(rel, {nm, ed, q}, visited) do @@ -98,12 +100,22 @@ defmodule LinearCli.CLI.Commands.Issues.Graph do {nm, ed, q} = add_endpoint(rel.related_issue, nm, ed, q, visited) source = rel.issue && rel.issue.identifier target = rel.related_issue && rel.related_issue.identifier - ed = if source && target, do: [%{source: source, target: target} | ed], else: ed + + both_known = + is_binary(source) and is_binary(target) and Map.has_key?(nm, source) and + Map.has_key?(nm, target) + + ed = if both_known, do: [%{source: source, target: target} | ed], else: ed {nm, ed, q} end defp add_endpoint(nil, nodes_map, edges, queue, _visited), do: {nodes_map, edges, queue} + defp add_endpoint(_endpoint, nodes_map, edges, queue, _visited) + when map_size(nodes_map) >= @max_nodes do + {nodes_map, edges, queue} + end + defp add_endpoint(endpoint, nodes_map, edges, queue, visited) do id = endpoint.identifier diff --git a/app/lib/linear_cli/cli/commands/issues/read.ex b/app/lib/linear_cli/cli/commands/issues/read.ex index c6c2103..f25a8ca 100644 --- a/app/lib/linear_cli/cli/commands/issues/read.ex +++ b/app/lib/linear_cli/cli/commands/issues/read.ex @@ -84,7 +84,7 @@ defmodule LinearCli.CLI.Commands.Issues.Read do expanded_id = Identifiers.expand_issue_id(issue_id) with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}), - {:ok, graph} <- Graph.build(expanded_id, issue) do + {:ok, graph} <- Graph.build(issue.identifier, issue) do Display.show_graph(graph, %{output: options.output}) :ok end diff --git a/app/lib/linear_cli/cli/display.ex b/app/lib/linear_cli/cli/display.ex index 9e5042f..1dafcf4 100644 --- a/app/lib/linear_cli/cli/display.ex +++ b/app/lib/linear_cli/cli/display.ex @@ -31,18 +31,19 @@ defmodule LinearCli.CLI.Display do end @doc """ - Prints a dependency graph produced by `LinearCli.CLI.Commands.Issues.Graph.build/1`. + Prints a dependency graph produced by `LinearCli.CLI.Commands.Issues.Graph.build/2`. With `--output json` emits only the structured graph object. Text output renders a diagram followed by Issues and Edges tables. """ - def show_graph(graph, opts \\ %{}) do - if Map.get(opts, :output, "text") == "json" do - graph |> graph_to_plain() |> Jason.encode!(pretty: true) |> IO.puts() - else - text = graph_text(graph) - Pager.maybe_page(text, opts) - end + def show_graph(graph, opts \\ %{}) + + def show_graph(graph, %{output: "json"}) do + graph |> graph_to_plain() |> Jason.encode!(pretty: true) |> IO.puts() + end + + def show_graph(graph, opts) do + Pager.maybe_page(graph_text(graph), opts) end defp format_text([%IssueRelation{} | _] = relations, _opts), do: relations_block(relations) @@ -299,20 +300,7 @@ defmodule LinearCli.CLI.Display do defp render_columns([], root_label, [single_succ], succ_ids, succ_succ_map) do succ_id = List.first(succ_ids) ss_labels = Map.get(succ_succ_map, succ_id, []) - - case ss_labels do - [] -> - "#{root_label} --> #{single_succ}" - - [one] -> - "#{root_label} --> #{single_succ} --> #{one}" - - many -> - first = "#{root_label} --> #{single_succ} --+--> #{List.first(many)}" - pad = String.duplicate(" ", String.length(root_label) + String.length(single_succ) + 9) - rest = Enum.map(Enum.drop(many, 1), &"#{pad}+--> #{&1}") - Enum.join([first | rest], "\n") - end + chain_succ(root_label, single_succ, ss_labels) end defp render_columns([], root_label, succs, _succ_ids, _succ_succ_map) do @@ -325,35 +313,35 @@ defmodule LinearCli.CLI.Display do defp render_columns(preds, root_label, succs, _succ_ids, _succ_succ_map) do pred_max = Enum.max(Enum.map(preds, &String.length/1)) mid = div(length(preds), 2) - - succ_arrow = - case succs do - [] -> - root_label - - [s] -> - "#{root_label} --> #{s}" - - [h | t] -> - pad = String.duplicate(" ", String.length(root_label) + 1) - first = "#{root_label} --+--> #{h}" - rest = Enum.map(t, &"#{pad} +--> #{&1}") - Enum.join([first | rest], "\n") - end + arrow = succ_arrow(root_label, succs) preds |> Enum.with_index() |> Enum.map_join("\n", fn {pl, i} -> padded = String.pad_trailing(pl, pred_max) - - if i == mid do - "#{padded} --+--> #{succ_arrow}" - else - "#{padded} --+" - end + if i == mid, do: "#{padded} --+--> #{arrow}", else: "#{padded} --+" end) end + defp chain_succ(root_label, succ, []), do: "#{root_label} --> #{succ}" + defp chain_succ(root_label, succ, [one]), do: "#{root_label} --> #{succ} --> #{one}" + + defp chain_succ(root_label, succ, [first | rest]) do + pad = String.duplicate(" ", String.length(root_label) + String.length(succ) + 9) + more = Enum.map(rest, &"#{pad}+--> #{&1}") + Enum.join(["#{root_label} --> #{succ} --+--> #{first}" | more], "\n") + end + + defp succ_arrow(root_label, []), do: root_label + defp succ_arrow(root_label, [s]), do: "#{root_label} --> #{s}" + + defp succ_arrow(root_label, [h | t]) do + pad = String.duplicate(" ", String.length(root_label) + 1) + first = "#{root_label} --+--> #{h}" + rest = Enum.map(t, &"#{pad} +--> #{&1}") + Enum.join([first | rest], "\n") + end + defp graph_issues_table(root, nodes) do id_w = nodes |> Enum.map(&String.length(&1.identifier)) |> Enum.max(fn -> 5 end) |> max(5) st_w = nodes |> Enum.map(&String.length(&1.status)) |> Enum.max(fn -> 6 end) |> max(6) From 0e6ca6831757a687ec5ce758f71a2a150e9c2362 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 12 Sep 2026 15:47:31 -0400 Subject: [PATCH 3/4] fix(issue): handle graph relation-fetch error in CLI error dispatcher Graph.build/2 returns {:error, {issue_id, reason}} when a relation fetch fails mid-traversal. The with in read.ex forwards this shape to handle_error/3, which had no clause for it and fell through to the WTH/exit-88 catch-all. Add a clause guarded on is_binary(issue_id) that prints the failing issue ID and re-dispatches the inner reason to the existing handlers. Co-Authored-By: Claude Sonnet 4.6 --- app/lib/linear_cli/cli.ex | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 0d58aa8..b18e63e 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -454,6 +454,14 @@ defmodule LinearCli.CLI do halt.(88) end + # Graph.build/2 returns {:error, {issue_id, reason}} to identify which + # issue's relations could not be fetched. Prefix context and re-dispatch + # so the underlying reason uses its own handler. + defp handle_error({issue_id, reason}, debug, halt) when is_binary(issue_id) do + IO.puts(:stderr, "could not fetch relations for #{issue_id}:") + handle_error(reason, debug, halt) + end + # Ported from CLI::Caller#call's catch-all `rescue StandardError` clause. defp handle_error(error, debug, halt) do IO.puts(:stderr, "What the heck is this? #{Exception.format_banner(:error, error)}") From 2add605ef63cfa690035048685715423d20d330c Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 12 Sep 2026 18:22:29 -0400 Subject: [PATCH 4/4] fix(display): correct ASCII + connector column alignment in graph diagram Three rendering paths had off-by-one or off-by-two misalignment: - chain_succ/3: pad was root+succ+9, must be root+succ+8 - render_columns/5 no-pred multi-succ: pad was root+1, must be root-1 (so the continuation + lands at root+3, matching the first line) - render_columns/5 preds+multi-succ: succ_arrow embedded in a pred line lost the predecessor column offset entirely; replaced with mid_pred_line/4 dispatch that computes cont_pad = pred_max + root_len + 11 Also adds CLI.Commands.Issues.Graph to AGENTS.md module index (maintenance convention) and adds a discovered-node status assertion to the graph test. --- AGENTS.md | 1 + app/lib/linear_cli/cli/display.ex | 26 +++++++++---------- .../cli/commands/issues/graph_test.exs | 1 + 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f361e15..e8307ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,7 @@ LinearCli.CLI — Entry point; Optimus argument parsing a CLI.Commands.Issues.Mutations — issue update/status/assign/comment subcommands CLI.Commands.Issues.Move — issue move subcommand CLI.Commands.Issues.Relations — issue relation list/add/remove subcommands + CLI.Commands.Issues.Graph — --graph transitive dependency graph builder CLI.Issue.Identifiers — issue ID expansion shared across subcommands CLI.WhatFor — Interactive prompts (team, project, label selection) CLI.Display — Output formatting helpers diff --git a/app/lib/linear_cli/cli/display.ex b/app/lib/linear_cli/cli/display.ex index 1dafcf4..a8cc1b0 100644 --- a/app/lib/linear_cli/cli/display.ex +++ b/app/lib/linear_cli/cli/display.ex @@ -305,7 +305,7 @@ defmodule LinearCli.CLI.Display do defp render_columns([], root_label, succs, _succ_ids, _succ_succ_map) do first_line = "#{root_label} --+--> #{List.first(succs)}" - pad = String.duplicate(" ", String.length(root_label) + 1) + pad = String.duplicate(" ", String.length(root_label) - 1) rest = Enum.map(Enum.drop(succs, 1), &"#{pad} +--> #{&1}") Enum.join([first_line | rest], "\n") end @@ -313,35 +313,33 @@ defmodule LinearCli.CLI.Display do defp render_columns(preds, root_label, succs, _succ_ids, _succ_succ_map) do pred_max = Enum.max(Enum.map(preds, &String.length/1)) mid = div(length(preds), 2) - arrow = succ_arrow(root_label, succs) preds |> Enum.with_index() |> Enum.map_join("\n", fn {pl, i} -> padded = String.pad_trailing(pl, pred_max) - if i == mid, do: "#{padded} --+--> #{arrow}", else: "#{padded} --+" + if i == mid, do: mid_pred_line(padded, pred_max, root_label, succs), else: "#{padded} --+" end) end + defp mid_pred_line(padded, _, root_label, []), do: "#{padded} --+--> #{root_label}" + defp mid_pred_line(padded, _, root_label, [s]), do: "#{padded} --+--> #{root_label} --> #{s}" + + defp mid_pred_line(padded, pred_max, root_label, [h | t]) do + first = "#{padded} --+--> #{root_label} --+--> #{h}" + cont_pad = String.duplicate(" ", pred_max + String.length(root_label) + 11) + Enum.join([first | Enum.map(t, &"#{cont_pad}+--> #{&1}")], "\n") + end + defp chain_succ(root_label, succ, []), do: "#{root_label} --> #{succ}" defp chain_succ(root_label, succ, [one]), do: "#{root_label} --> #{succ} --> #{one}" defp chain_succ(root_label, succ, [first | rest]) do - pad = String.duplicate(" ", String.length(root_label) + String.length(succ) + 9) + pad = String.duplicate(" ", String.length(root_label) + String.length(succ) + 8) more = Enum.map(rest, &"#{pad}+--> #{&1}") Enum.join(["#{root_label} --> #{succ} --+--> #{first}" | more], "\n") end - defp succ_arrow(root_label, []), do: root_label - defp succ_arrow(root_label, [s]), do: "#{root_label} --> #{s}" - - defp succ_arrow(root_label, [h | t]) do - pad = String.duplicate(" ", String.length(root_label) + 1) - first = "#{root_label} --+--> #{h}" - rest = Enum.map(t, &"#{pad} +--> #{&1}") - Enum.join([first | rest], "\n") - end - defp graph_issues_table(root, nodes) do id_w = nodes |> Enum.map(&String.length(&1.identifier)) |> Enum.max(fn -> 5 end) |> max(5) st_w = nodes |> Enum.map(&String.length(&1.status)) |> Enum.max(fn -> 6 end) |> max(6) diff --git a/app/test/linear_cli/cli/commands/issues/graph_test.exs b/app/test/linear_cli/cli/commands/issues/graph_test.exs index af5e6ea..878b181 100644 --- a/app/test/linear_cli/cli/commands/issues/graph_test.exs +++ b/app/test/linear_cli/cli/commands/issues/graph_test.exs @@ -130,6 +130,7 @@ defmodule LinearCli.CLI.Commands.Issues.GraphTest do assert "EXT-56" in ids assert "EXT-57" in ids assert graph.edges == [%{source: "EXT-56", target: "EXT-57"}] + assert Enum.find(graph.nodes, &(&1.identifier == "EXT-57")).status == "Todo" end test "single inbound blocks edge (EXT-40 blocks root)" do