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>
This commit is contained in:
341
cmd/bd/graph.go
Normal file
341
cmd/bd/graph.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/utils"
|
||||
)
|
||||
|
||||
// GraphNode represents a node in the rendered graph
|
||||
type GraphNode struct {
|
||||
Issue *types.Issue
|
||||
Layer int // Horizontal layer (topological order)
|
||||
Position int // Vertical position within layer
|
||||
DependsOn []string // IDs this node depends on (blocks dependencies only)
|
||||
}
|
||||
|
||||
// GraphLayout holds the computed graph layout
|
||||
type GraphLayout struct {
|
||||
Nodes map[string]*GraphNode
|
||||
Layers [][]string // Layer index -> node IDs in that layer
|
||||
MaxLayer int
|
||||
RootID string
|
||||
}
|
||||
|
||||
var graphCmd = &cobra.Command{
|
||||
Use: "graph <issue-id>",
|
||||
Short: "Display issue dependency graph",
|
||||
Long: `Display an ASCII visualization of an issue's dependency graph.
|
||||
|
||||
For epics, shows all children and their dependencies.
|
||||
For regular issues, shows the issue and its direct dependencies.
|
||||
|
||||
The graph shows execution order left-to-right:
|
||||
- Leftmost nodes have no dependencies (can start immediately)
|
||||
- Rightmost nodes depend on everything to their left
|
||||
- Nodes in the same column can run in parallel
|
||||
|
||||
Colors indicate status:
|
||||
- White: open (ready to work)
|
||||
- Yellow: in progress
|
||||
- Red: blocked
|
||||
- Green: closed`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
ctx := rootCtx
|
||||
var issueID string
|
||||
|
||||
// Resolve the issue ID
|
||||
if daemonClient != nil {
|
||||
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]}
|
||||
resp, err := daemonClient.ResolveID(resolveArgs)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: issue '%s' not found\n", args[0])
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := json.Unmarshal(resp.Data, &issueID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else if store != nil {
|
||||
var err error
|
||||
issueID, err = utils.ResolvePartialID(ctx, store, args[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: issue '%s' not found\n", args[0])
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Load the subgraph
|
||||
subgraph, err := loadGraphSubgraph(ctx, store, issueID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading graph: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Compute layout
|
||||
layout := computeLayout(subgraph)
|
||||
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"root": subgraph.Root,
|
||||
"issues": subgraph.Issues,
|
||||
"layout": layout,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Render ASCII graph
|
||||
renderGraph(layout, subgraph)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(graphCmd)
|
||||
}
|
||||
|
||||
// loadGraphSubgraph loads an issue and its subgraph for visualization
|
||||
// Reuses template subgraph loading logic
|
||||
func loadGraphSubgraph(ctx context.Context, s storage.Storage, issueID string) (*TemplateSubgraph, error) {
|
||||
return loadTemplateSubgraph(ctx, s, issueID)
|
||||
}
|
||||
|
||||
// computeLayout assigns layers to nodes using topological sort
|
||||
func computeLayout(subgraph *TemplateSubgraph) *GraphLayout {
|
||||
layout := &GraphLayout{
|
||||
Nodes: make(map[string]*GraphNode),
|
||||
RootID: subgraph.Root.ID,
|
||||
}
|
||||
|
||||
// Build dependency map (only "blocks" dependencies, not parent-child)
|
||||
dependsOn := make(map[string][]string)
|
||||
blockedBy := make(map[string][]string)
|
||||
|
||||
for _, dep := range subgraph.Dependencies {
|
||||
if dep.Type == types.DepBlocks {
|
||||
// dep.IssueID depends on dep.DependsOnID
|
||||
dependsOn[dep.IssueID] = append(dependsOn[dep.IssueID], dep.DependsOnID)
|
||||
blockedBy[dep.DependsOnID] = append(blockedBy[dep.DependsOnID], dep.IssueID)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize nodes
|
||||
for _, issue := range subgraph.Issues {
|
||||
layout.Nodes[issue.ID] = &GraphNode{
|
||||
Issue: issue,
|
||||
Layer: -1, // Unassigned
|
||||
DependsOn: dependsOn[issue.ID],
|
||||
}
|
||||
}
|
||||
|
||||
// Assign layers using longest path from sources
|
||||
// Layer 0 = nodes with no dependencies
|
||||
changed := true
|
||||
for changed {
|
||||
changed = false
|
||||
for id, node := range layout.Nodes {
|
||||
if node.Layer >= 0 {
|
||||
continue // Already assigned
|
||||
}
|
||||
|
||||
deps := dependsOn[id]
|
||||
if len(deps) == 0 {
|
||||
// No dependencies - layer 0
|
||||
node.Layer = 0
|
||||
changed = true
|
||||
} else {
|
||||
// Check if all dependencies have layers assigned
|
||||
maxDepLayer := -1
|
||||
allAssigned := true
|
||||
for _, depID := range deps {
|
||||
depNode := layout.Nodes[depID]
|
||||
if depNode == nil || depNode.Layer < 0 {
|
||||
allAssigned = false
|
||||
break
|
||||
}
|
||||
if depNode.Layer > maxDepLayer {
|
||||
maxDepLayer = depNode.Layer
|
||||
}
|
||||
}
|
||||
if allAssigned {
|
||||
node.Layer = maxDepLayer + 1
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any unassigned nodes (cycles or disconnected)
|
||||
for _, node := range layout.Nodes {
|
||||
if node.Layer < 0 {
|
||||
node.Layer = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Build layers array
|
||||
for _, node := range layout.Nodes {
|
||||
if node.Layer > layout.MaxLayer {
|
||||
layout.MaxLayer = node.Layer
|
||||
}
|
||||
}
|
||||
|
||||
layout.Layers = make([][]string, layout.MaxLayer+1)
|
||||
for id, node := range layout.Nodes {
|
||||
layout.Layers[node.Layer] = append(layout.Layers[node.Layer], id)
|
||||
}
|
||||
|
||||
// Sort nodes within each layer for consistent ordering
|
||||
for i := range layout.Layers {
|
||||
sort.Strings(layout.Layers[i])
|
||||
}
|
||||
|
||||
// Assign vertical positions within layers
|
||||
for _, layer := range layout.Layers {
|
||||
for pos, id := range layer {
|
||||
layout.Nodes[id].Position = pos
|
||||
}
|
||||
}
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
// renderGraph renders the ASCII visualization
|
||||
func renderGraph(layout *GraphLayout, subgraph *TemplateSubgraph) {
|
||||
if len(layout.Nodes) == 0 {
|
||||
fmt.Println("Empty graph")
|
||||
return
|
||||
}
|
||||
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
fmt.Printf("\n%s Dependency graph for %s:\n\n", cyan("📊"), layout.RootID)
|
||||
|
||||
// Calculate box width based on longest title
|
||||
maxTitleLen := 0
|
||||
for _, node := range layout.Nodes {
|
||||
titleLen := len(truncateTitle(node.Issue.Title, 30))
|
||||
if titleLen > maxTitleLen {
|
||||
maxTitleLen = titleLen
|
||||
}
|
||||
}
|
||||
boxWidth := maxTitleLen + 4 // padding
|
||||
|
||||
// Render each layer
|
||||
// For simplicity, we'll render layer by layer with arrows between them
|
||||
|
||||
// First, show the legend
|
||||
fmt.Println(" Status: ○ open ◐ in_progress ● blocked ✓ closed")
|
||||
fmt.Println()
|
||||
|
||||
// Render layers left to right
|
||||
layerBoxes := make([][]string, len(layout.Layers))
|
||||
|
||||
for layerIdx, layer := range layout.Layers {
|
||||
var boxes []string
|
||||
for _, id := range layer {
|
||||
node := layout.Nodes[id]
|
||||
box := renderNodeBox(node, boxWidth)
|
||||
boxes = append(boxes, box)
|
||||
}
|
||||
layerBoxes[layerIdx] = boxes
|
||||
}
|
||||
|
||||
// Find max height per layer
|
||||
maxHeight := 0
|
||||
for _, boxes := range layerBoxes {
|
||||
h := len(boxes) * 4 // Each box is ~3 lines + 1 gap
|
||||
if h > maxHeight {
|
||||
maxHeight = h
|
||||
}
|
||||
}
|
||||
|
||||
// Render horizontally (simplified - just show boxes with arrows)
|
||||
for layerIdx, boxes := range layerBoxes {
|
||||
// Print layer header
|
||||
fmt.Printf(" Layer %d", layerIdx)
|
||||
if layerIdx == 0 {
|
||||
fmt.Print(" (ready)")
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
for _, box := range boxes {
|
||||
fmt.Println(box)
|
||||
}
|
||||
|
||||
// Print arrows to next layer if not last
|
||||
if layerIdx < len(layerBoxes)-1 {
|
||||
fmt.Println(" │")
|
||||
fmt.Println(" ▼")
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Show summary
|
||||
fmt.Printf(" Total: %d issues across %d layers\n\n", len(layout.Nodes), len(layout.Layers))
|
||||
}
|
||||
|
||||
// renderNodeBox renders a single node as an ASCII box
|
||||
func renderNodeBox(node *GraphNode, width 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 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)))
|
||||
bottom := " └" + strings.Repeat("─", width) + "┘"
|
||||
|
||||
return topBottom + "\n" + middle + "\n" + idLine + "\n" + bottom
|
||||
}
|
||||
|
||||
// truncateTitle truncates a title to max length (rune-safe)
|
||||
func truncateTitle(title string, maxLen int) string {
|
||||
runes := []rune(title)
|
||||
if len(runes) <= maxLen {
|
||||
return title
|
||||
}
|
||||
return string(runes[:maxLen-1]) + "…"
|
||||
}
|
||||
|
||||
// padRight pads a string to the right with spaces (rune-safe)
|
||||
func padRight(s string, width int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) >= width {
|
||||
return string(runes[:width])
|
||||
}
|
||||
return s + strings.Repeat(" ", width-len(runes))
|
||||
}
|
||||
Reference in New Issue
Block a user