Skip to content

Commit a89de1a

Browse files
steveyeggeclaude
andcommitted
feat(graph): add bd graph command for ASCII DAG visualization
New command to visualize issue dependency graphs: - Layered layout (Sugiyama-style) shows execution order - Status coloring (open/in_progress/blocked/closed) - Works with epics to show full subgraph - Layer 0 = ready tasks (no blockers) Usage: bd graph <issue-id> Part of bd-r6a workflow system redesign. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 125e36d commit a89de1a

1 file changed

Lines changed: 341 additions & 0 deletions

File tree

cmd/bd/graph.go

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"sort"
9+
"strings"
10+
11+
"github.com/fatih/color"
12+
"github.com/spf13/cobra"
13+
"github.com/steveyegge/beads/internal/rpc"
14+
"github.com/steveyegge/beads/internal/storage"
15+
"github.com/steveyegge/beads/internal/types"
16+
"github.com/steveyegge/beads/internal/utils"
17+
)
18+
19+
// GraphNode represents a node in the rendered graph
20+
type GraphNode struct {
21+
Issue *types.Issue
22+
Layer int // Horizontal layer (topological order)
23+
Position int // Vertical position within layer
24+
DependsOn []string // IDs this node depends on (blocks dependencies only)
25+
}
26+
27+
// GraphLayout holds the computed graph layout
28+
type GraphLayout struct {
29+
Nodes map[string]*GraphNode
30+
Layers [][]string // Layer index -> node IDs in that layer
31+
MaxLayer int
32+
RootID string
33+
}
34+
35+
var graphCmd = &cobra.Command{
36+
Use: "graph <issue-id>",
37+
Short: "Display issue dependency graph",
38+
Long: `Display an ASCII visualization of an issue's dependency graph.
39+
40+
For epics, shows all children and their dependencies.
41+
For regular issues, shows the issue and its direct dependencies.
42+
43+
The graph shows execution order left-to-right:
44+
- Leftmost nodes have no dependencies (can start immediately)
45+
- Rightmost nodes depend on everything to their left
46+
- Nodes in the same column can run in parallel
47+
48+
Colors indicate status:
49+
- White: open (ready to work)
50+
- Yellow: in progress
51+
- Red: blocked
52+
- Green: closed`,
53+
Args: cobra.ExactArgs(1),
54+
Run: func(cmd *cobra.Command, args []string) {
55+
ctx := rootCtx
56+
var issueID string
57+
58+
// Resolve the issue ID
59+
if daemonClient != nil {
60+
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]}
61+
resp, err := daemonClient.ResolveID(resolveArgs)
62+
if err != nil {
63+
fmt.Fprintf(os.Stderr, "Error: issue '%s' not found\n", args[0])
64+
os.Exit(1)
65+
}
66+
if err := json.Unmarshal(resp.Data, &issueID); err != nil {
67+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
68+
os.Exit(1)
69+
}
70+
} else if store != nil {
71+
var err error
72+
issueID, err = utils.ResolvePartialID(ctx, store, args[0])
73+
if err != nil {
74+
fmt.Fprintf(os.Stderr, "Error: issue '%s' not found\n", args[0])
75+
os.Exit(1)
76+
}
77+
} else {
78+
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
79+
os.Exit(1)
80+
}
81+
82+
// Load the subgraph
83+
subgraph, err := loadGraphSubgraph(ctx, store, issueID)
84+
if err != nil {
85+
fmt.Fprintf(os.Stderr, "Error loading graph: %v\n", err)
86+
os.Exit(1)
87+
}
88+
89+
// Compute layout
90+
layout := computeLayout(subgraph)
91+
92+
if jsonOutput {
93+
outputJSON(map[string]interface{}{
94+
"root": subgraph.Root,
95+
"issues": subgraph.Issues,
96+
"layout": layout,
97+
})
98+
return
99+
}
100+
101+
// Render ASCII graph
102+
renderGraph(layout, subgraph)
103+
},
104+
}
105+
106+
func init() {
107+
rootCmd.AddCommand(graphCmd)
108+
}
109+
110+
// loadGraphSubgraph loads an issue and its subgraph for visualization
111+
// Reuses template subgraph loading logic
112+
func loadGraphSubgraph(ctx context.Context, s storage.Storage, issueID string) (*TemplateSubgraph, error) {
113+
return loadTemplateSubgraph(ctx, s, issueID)
114+
}
115+
116+
// computeLayout assigns layers to nodes using topological sort
117+
func computeLayout(subgraph *TemplateSubgraph) *GraphLayout {
118+
layout := &GraphLayout{
119+
Nodes: make(map[string]*GraphNode),
120+
RootID: subgraph.Root.ID,
121+
}
122+
123+
// Build dependency map (only "blocks" dependencies, not parent-child)
124+
dependsOn := make(map[string][]string)
125+
blockedBy := make(map[string][]string)
126+
127+
for _, dep := range subgraph.Dependencies {
128+
if dep.Type == types.DepBlocks {
129+
// dep.IssueID depends on dep.DependsOnID
130+
dependsOn[dep.IssueID] = append(dependsOn[dep.IssueID], dep.DependsOnID)
131+
blockedBy[dep.DependsOnID] = append(blockedBy[dep.DependsOnID], dep.IssueID)
132+
}
133+
}
134+
135+
// Initialize nodes
136+
for _, issue := range subgraph.Issues {
137+
layout.Nodes[issue.ID] = &GraphNode{
138+
Issue: issue,
139+
Layer: -1, // Unassigned
140+
DependsOn: dependsOn[issue.ID],
141+
}
142+
}
143+
144+
// Assign layers using longest path from sources
145+
// Layer 0 = nodes with no dependencies
146+
changed := true
147+
for changed {
148+
changed = false
149+
for id, node := range layout.Nodes {
150+
if node.Layer >= 0 {
151+
continue // Already assigned
152+
}
153+
154+
deps := dependsOn[id]
155+
if len(deps) == 0 {
156+
// No dependencies - layer 0
157+
node.Layer = 0
158+
changed = true
159+
} else {
160+
// Check if all dependencies have layers assigned
161+
maxDepLayer := -1
162+
allAssigned := true
163+
for _, depID := range deps {
164+
depNode := layout.Nodes[depID]
165+
if depNode == nil || depNode.Layer < 0 {
166+
allAssigned = false
167+
break
168+
}
169+
if depNode.Layer > maxDepLayer {
170+
maxDepLayer = depNode.Layer
171+
}
172+
}
173+
if allAssigned {
174+
node.Layer = maxDepLayer + 1
175+
changed = true
176+
}
177+
}
178+
}
179+
}
180+
181+
// Handle any unassigned nodes (cycles or disconnected)
182+
for _, node := range layout.Nodes {
183+
if node.Layer < 0 {
184+
node.Layer = 0
185+
}
186+
}
187+
188+
// Build layers array
189+
for _, node := range layout.Nodes {
190+
if node.Layer > layout.MaxLayer {
191+
layout.MaxLayer = node.Layer
192+
}
193+
}
194+
195+
layout.Layers = make([][]string, layout.MaxLayer+1)
196+
for id, node := range layout.Nodes {
197+
layout.Layers[node.Layer] = append(layout.Layers[node.Layer], id)
198+
}
199+
200+
// Sort nodes within each layer for consistent ordering
201+
for i := range layout.Layers {
202+
sort.Strings(layout.Layers[i])
203+
}
204+
205+
// Assign vertical positions within layers
206+
for _, layer := range layout.Layers {
207+
for pos, id := range layer {
208+
layout.Nodes[id].Position = pos
209+
}
210+
}
211+
212+
return layout
213+
}
214+
215+
// renderGraph renders the ASCII visualization
216+
func renderGraph(layout *GraphLayout, subgraph *TemplateSubgraph) {
217+
if len(layout.Nodes) == 0 {
218+
fmt.Println("Empty graph")
219+
return
220+
}
221+
222+
cyan := color.New(color.FgCyan).SprintFunc()
223+
fmt.Printf("\n%s Dependency graph for %s:\n\n", cyan("📊"), layout.RootID)
224+
225+
// Calculate box width based on longest title
226+
maxTitleLen := 0
227+
for _, node := range layout.Nodes {
228+
titleLen := len(truncateTitle(node.Issue.Title, 30))
229+
if titleLen > maxTitleLen {
230+
maxTitleLen = titleLen
231+
}
232+
}
233+
boxWidth := maxTitleLen + 4 // padding
234+
235+
// Render each layer
236+
// For simplicity, we'll render layer by layer with arrows between them
237+
238+
// First, show the legend
239+
fmt.Println(" Status: ○ open ◐ in_progress ● blocked ✓ closed")
240+
fmt.Println()
241+
242+
// Render layers left to right
243+
layerBoxes := make([][]string, len(layout.Layers))
244+
245+
for layerIdx, layer := range layout.Layers {
246+
var boxes []string
247+
for _, id := range layer {
248+
node := layout.Nodes[id]
249+
box := renderNodeBox(node, boxWidth)
250+
boxes = append(boxes, box)
251+
}
252+
layerBoxes[layerIdx] = boxes
253+
}
254+
255+
// Find max height per layer
256+
maxHeight := 0
257+
for _, boxes := range layerBoxes {
258+
h := len(boxes) * 4 // Each box is ~3 lines + 1 gap
259+
if h > maxHeight {
260+
maxHeight = h
261+
}
262+
}
263+
264+
// Render horizontally (simplified - just show boxes with arrows)
265+
for layerIdx, boxes := range layerBoxes {
266+
// Print layer header
267+
fmt.Printf(" Layer %d", layerIdx)
268+
if layerIdx == 0 {
269+
fmt.Print(" (ready)")
270+
}
271+
fmt.Println()
272+
273+
for _, box := range boxes {
274+
fmt.Println(box)
275+
}
276+
277+
// Print arrows to next layer if not last
278+
if layerIdx < len(layerBoxes)-1 {
279+
fmt.Println(" │")
280+
fmt.Println(" ▼")
281+
}
282+
fmt.Println()
283+
}
284+
285+
// Show summary
286+
fmt.Printf(" Total: %d issues across %d layers\n\n", len(layout.Nodes), len(layout.Layers))
287+
}
288+
289+
// renderNodeBox renders a single node as an ASCII box
290+
func renderNodeBox(node *GraphNode, width int) string {
291+
// Status indicator
292+
var statusIcon string
293+
var colorFn func(a ...interface{}) string
294+
295+
switch node.Issue.Status {
296+
case types.StatusOpen:
297+
statusIcon = "○"
298+
colorFn = color.New(color.FgWhite).SprintFunc()
299+
case types.StatusInProgress:
300+
statusIcon = "◐"
301+
colorFn = color.New(color.FgYellow).SprintFunc()
302+
case types.StatusBlocked:
303+
statusIcon = "●"
304+
colorFn = color.New(color.FgRed).SprintFunc()
305+
case types.StatusClosed:
306+
statusIcon = "✓"
307+
colorFn = color.New(color.FgGreen).SprintFunc()
308+
default:
309+
statusIcon = "?"
310+
colorFn = color.New(color.FgWhite).SprintFunc()
311+
}
312+
313+
title := truncateTitle(node.Issue.Title, width-4)
314+
id := node.Issue.ID
315+
316+
// Build the box
317+
topBottom := " ┌" + strings.Repeat("─", width) + "┐"
318+
middle := fmt.Sprintf(" │ %s %s │", statusIcon, colorFn(padRight(title, width-4)))
319+
idLine := fmt.Sprintf(" │ %s │", color.New(color.FgHiBlack).Sprint(padRight(id, width-2)))
320+
bottom := " └" + strings.Repeat("─", width) + "┘"
321+
322+
return topBottom + "\n" + middle + "\n" + idLine + "\n" + bottom
323+
}
324+
325+
// truncateTitle truncates a title to max length (rune-safe)
326+
func truncateTitle(title string, maxLen int) string {
327+
runes := []rune(title)
328+
if len(runes) <= maxLen {
329+
return title
330+
}
331+
return string(runes[:maxLen-1]) + "…"
332+
}
333+
334+
// padRight pads a string to the right with spaces (rune-safe)
335+
func padRight(s string, width int) string {
336+
runes := []rune(s)
337+
if len(runes) >= width {
338+
return string(runes[:width])
339+
}
340+
return s + strings.Repeat(" ", width-len(runes))
341+
}

0 commit comments

Comments
 (0)