feat(graph): use subgraph to display dependency counts

Restore the subgraph parameter in renderGraph (previously marked as
unused with _) and use it to display meaningful dependency information:

- Add computeDependencyCounts() to calculate blocks/blocked-by counts
- Add renderNodeBoxWithDeps() to render nodes with dependency info
- Show "blocks:N" when an issue blocks N other issues
- Show "needs:N" when an issue depends on N other issues
- Add dependency summary showing total blocking relationships

This makes the graph visualization more informative by showing how
issues relate to each other in the dependency chain.

Tests added:
- TestComputeDependencyCounts: verifies dependency counting logic
- TestRenderNodeBoxWithDeps: verifies box rendering with dep info

Co-authored-by: Charles P. Cross <cpdata@users.noreply.github.com>
This commit is contained in:
Charles P. Cross
2025-12-18 23:35:00 -05:00
committed by GitHub
parent cb5ebfc667
commit 77d8a995e8
2 changed files with 232 additions and 2 deletions

View File

@@ -277,7 +277,7 @@ func computeLayout(subgraph *TemplateSubgraph) *GraphLayout {
}
// renderGraph renders the ASCII visualization
func renderGraph(layout *GraphLayout, _ *TemplateSubgraph) {
func renderGraph(layout *GraphLayout, subgraph *TemplateSubgraph) {
if len(layout.Nodes) == 0 {
fmt.Println("Empty graph")
return
@@ -303,6 +303,9 @@ func renderGraph(layout *GraphLayout, _ *TemplateSubgraph) {
fmt.Println(" Status: ○ open ◐ in_progress ● blocked ✓ closed")
fmt.Println()
// Build dependency counts from subgraph
blocksCounts, blockedByCounts := computeDependencyCounts(subgraph)
// Render layers left to right
layerBoxes := make([][]string, len(layout.Layers))
@@ -310,7 +313,7 @@ func renderGraph(layout *GraphLayout, _ *TemplateSubgraph) {
var boxes []string
for _, id := range layer {
node := layout.Nodes[id]
box := renderNodeBox(node, boxWidth)
box := renderNodeBoxWithDeps(node, boxWidth, blocksCounts[id], blockedByCounts[id])
boxes = append(boxes, box)
}
layerBoxes[layerIdx] = boxes
@@ -346,6 +349,19 @@ func renderGraph(layout *GraphLayout, _ *TemplateSubgraph) {
fmt.Println()
}
// Show dependency summary
if len(subgraph.Dependencies) > 0 {
blocksDeps := 0
for _, dep := range subgraph.Dependencies {
if dep.Type == types.DepBlocks {
blocksDeps++
}
}
if blocksDeps > 0 {
fmt.Printf(" Dependencies: %d blocking relationships\n", blocksDeps)
}
}
// Show summary
fmt.Printf(" Total: %d issues across %d layers\n\n", len(layout.Nodes), len(layout.Layers))
}
@@ -403,3 +419,79 @@ func padRight(s string, width int) string {
}
return s + strings.Repeat(" ", width-len(runes))
}
// computeDependencyCounts calculates how many issues each issue blocks and is blocked by
func computeDependencyCounts(subgraph *TemplateSubgraph) (blocks map[string]int, blockedBy map[string]int) {
blocks = make(map[string]int)
blockedBy = make(map[string]int)
for _, dep := range subgraph.Dependencies {
if dep.Type == types.DepBlocks {
// dep.DependsOnID blocks dep.IssueID
// So dep.DependsOnID "blocks" count increases
blocks[dep.DependsOnID]++
// And dep.IssueID "blocked by" count increases
blockedBy[dep.IssueID]++
}
}
return blocks, blockedBy
}
// renderNodeBoxWithDeps renders a node box with dependency information
func renderNodeBoxWithDeps(node *GraphNode, width int, blocksCount int, blockedByCount int) string {
// Status indicator
var statusIcon string
var colorFn func(a ...interface{}) string
switch node.Issue.Status {
case types.StatusOpen:
statusIcon = "○"
colorFn = color.New(color.FgWhite).SprintFunc()
case types.StatusInProgress:
statusIcon = "◐"
colorFn = color.New(color.FgYellow).SprintFunc()
case types.StatusBlocked:
statusIcon = "●"
colorFn = color.New(color.FgRed).SprintFunc()
case types.StatusClosed:
statusIcon = "✓"
colorFn = color.New(color.FgGreen).SprintFunc()
default:
statusIcon = "?"
colorFn = color.New(color.FgWhite).SprintFunc()
}
title := truncateTitle(node.Issue.Title, width-4)
id := node.Issue.ID
// Build dependency info string
var depInfo string
if blocksCount > 0 || blockedByCount > 0 {
parts := []string{}
if blocksCount > 0 {
parts = append(parts, fmt.Sprintf("blocks:%d", blocksCount))
}
if blockedByCount > 0 {
parts = append(parts, fmt.Sprintf("needs:%d", blockedByCount))
}
depInfo = strings.Join(parts, " ")
}
// Build the box
topBottom := " ┌" + strings.Repeat("─", width) + "┐"
middle := fmt.Sprintf(" │ %s %s │", statusIcon, colorFn(padRight(title, width-4)))
idLine := fmt.Sprintf(" │ %s │", color.New(color.FgHiBlack).Sprint(padRight(id, width-2)))
var result string
if depInfo != "" {
depLine := fmt.Sprintf(" │ %s │", color.New(color.FgCyan).Sprint(padRight(depInfo, width-2)))
bottom := " └" + strings.Repeat("─", width) + "┘"
result = topBottom + "\n" + middle + "\n" + idLine + "\n" + depLine + "\n" + bottom
} else {
bottom := " └" + strings.Repeat("─", width) + "┘"
result = topBottom + "\n" + middle + "\n" + idLine + "\n" + bottom
}
return result
}

View File

@@ -201,6 +201,144 @@ func stringContains(s, substr string) bool {
return false
}
func TestComputeDependencyCounts(t *testing.T) {
t.Run("empty subgraph", func(t *testing.T) {
subgraph := &TemplateSubgraph{
Root: &types.Issue{ID: "root-1"},
Issues: []*types.Issue{},
Dependencies: []*types.Dependency{},
IssueMap: make(map[string]*types.Issue),
}
blocks, blockedBy := computeDependencyCounts(subgraph)
if len(blocks) != 0 {
t.Errorf("expected empty blocks map, got %d entries", len(blocks))
}
if len(blockedBy) != 0 {
t.Errorf("expected empty blockedBy map, got %d entries", len(blockedBy))
}
})
t.Run("single blocking dependency", func(t *testing.T) {
subgraph := &TemplateSubgraph{
Root: &types.Issue{ID: "root-1"},
Dependencies: []*types.Dependency{
{IssueID: "issue-2", DependsOnID: "issue-1", Type: types.DepBlocks},
},
}
blocks, blockedBy := computeDependencyCounts(subgraph)
if blocks["issue-1"] != 1 {
t.Errorf("expected issue-1 to block 1, got %d", blocks["issue-1"])
}
if blockedBy["issue-2"] != 1 {
t.Errorf("expected issue-2 to be blocked by 1, got %d", blockedBy["issue-2"])
}
})
t.Run("multiple dependencies", func(t *testing.T) {
subgraph := &TemplateSubgraph{
Root: &types.Issue{ID: "root-1"},
Dependencies: []*types.Dependency{
{IssueID: "issue-2", DependsOnID: "issue-1", Type: types.DepBlocks},
{IssueID: "issue-3", DependsOnID: "issue-1", Type: types.DepBlocks},
{IssueID: "issue-3", DependsOnID: "issue-2", Type: types.DepBlocks},
},
}
blocks, blockedBy := computeDependencyCounts(subgraph)
// issue-1 blocks 2 issues (issue-2 and issue-3)
if blocks["issue-1"] != 2 {
t.Errorf("expected issue-1 to block 2, got %d", blocks["issue-1"])
}
// issue-2 blocks 1 issue (issue-3)
if blocks["issue-2"] != 1 {
t.Errorf("expected issue-2 to block 1, got %d", blocks["issue-2"])
}
// issue-3 is blocked by 2 issues
if blockedBy["issue-3"] != 2 {
t.Errorf("expected issue-3 to be blocked by 2, got %d", blockedBy["issue-3"])
}
})
t.Run("ignores non-blocks dependencies", func(t *testing.T) {
subgraph := &TemplateSubgraph{
Root: &types.Issue{ID: "root-1"},
Dependencies: []*types.Dependency{
{IssueID: "issue-2", DependsOnID: "issue-1", Type: types.DepParentChild},
{IssueID: "issue-3", DependsOnID: "issue-1", Type: types.DepRelatesTo},
},
}
blocks, blockedBy := computeDependencyCounts(subgraph)
if len(blocks) != 0 {
t.Errorf("expected empty blocks map for non-blocks deps, got %d entries", len(blocks))
}
if len(blockedBy) != 0 {
t.Errorf("expected empty blockedBy map for non-blocks deps, got %d entries", len(blockedBy))
}
})
}
func TestRenderNodeBoxWithDeps(t *testing.T) {
tests := []struct {
name string
node *GraphNode
width int
blocksCount int
blockedByCount int
wantContains []string
}{
{
name: "no dependencies",
node: &GraphNode{
Issue: &types.Issue{ID: "test-1", Title: "Test", Status: types.StatusOpen},
},
width: 20,
blocksCount: 0,
blockedByCount: 0,
wantContains: []string{"test-1", "Test"},
},
{
name: "with blocks count",
node: &GraphNode{
Issue: &types.Issue{ID: "test-2", Title: "Blocker", Status: types.StatusOpen},
},
width: 20,
blocksCount: 3,
blockedByCount: 0,
wantContains: []string{"test-2", "Blocker", "blocks:3"},
},
{
name: "with needs count",
node: &GraphNode{
Issue: &types.Issue{ID: "test-3", Title: "Blocked", Status: types.StatusBlocked},
},
width: 20,
blocksCount: 0,
blockedByCount: 2,
wantContains: []string{"test-3", "Blocked", "needs:2"},
},
{
name: "with both counts",
node: &GraphNode{
Issue: &types.Issue{ID: "test-4", Title: "Middle", Status: types.StatusInProgress},
},
width: 25,
blocksCount: 1,
blockedByCount: 2,
wantContains: []string{"test-4", "Middle", "blocks:1", "needs:2"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := renderNodeBoxWithDeps(tt.node, tt.width, tt.blocksCount, tt.blockedByCount)
for _, want := range tt.wantContains {
if !stringContains(got, want) {
t.Errorf("renderNodeBoxWithDeps() missing %q in output:\n%s", want, got)
}
}
})
}
}
func TestComputeLayout(t *testing.T) {
t.Run("empty subgraph", func(t *testing.T) {
subgraph := &TemplateSubgraph{