-
Notifications
You must be signed in to change notification settings - Fork 0
feat(issues): add --graph flag to issue view for transitive dependency graph #273
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
daf6fb2
feat(issues): add --graph flag to issue view for transitive dependenc…
ruby-automation 7c0ed6c
refactor(issue): address PR review feedback on --graph flag
ruby-automation 0e6ca68
fix(issue): handle graph relation-fetch error in CLI error dispatcher
ruby-automation 2add605
fix(display): correct ASCII + connector column alignment in graph dia…
ruby-automation File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.