From a89de1ac8b1a7ac14bc67689b4caa7e4eb4faf8b Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 18 Dec 2025 13:47:45 -0800 Subject: [PATCH] feat(graph): add bd graph command for ASCII DAG visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Part of bd-r6a workflow system redesign. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/graph.go | 341 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 cmd/bd/graph.go diff --git a/cmd/bd/graph.go b/cmd/bd/graph.go new file mode 100644 index 00000000..bbe2970e --- /dev/null +++ b/cmd/bd/graph.go @@ -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 ", + 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)) +}