diff --git a/cmd/bd/graph.go b/cmd/bd/graph.go index bfe3d431..53e54789 100644 --- a/cmd/bd/graph.go +++ b/cmd/bd/graph.go @@ -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 +} diff --git a/cmd/bd/graph_test.go b/cmd/bd/graph_test.go index f60c870b..55c14786 100644 --- a/cmd/bd/graph_test.go +++ b/cmd/bd/graph_test.go @@ -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{