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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user