Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion app/lib/linear_cli/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)}")
Expand Down Expand Up @@ -750,7 +758,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: [
Expand Down
136 changes: 136 additions & 0 deletions app/lib/linear_cli/cli/commands/issues/graph.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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
}
}

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

# 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
visit(
MapSet.member?(visited, id) or map_size(nodes_map) >= @max_nodes,
id,
rest,
visited,
nodes_map,
edges
)
end

defp visit(true, _id, rest, visited, nodes_map, edges), do: bfs(rest, visited, nodes_map, edges)

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

defp traverse({:error, reason}, id, _rest, _visited, _nodes_map, _edges),
do: {:error, {id, reason}}

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
{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

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

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
37 changes: 28 additions & 9 deletions app/lib/linear_cli/cli/commands/issues/read.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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(issue.identifier, issue) do
Display.show_graph(graph, %{output: options.output})
:ok
end
Comment thread
bougyman marked this conversation as resolved.

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

Expand Down
Loading