package main import ( "context" "encoding/json" "fmt" "os" "sort" "strings" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/ui" "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 ( graphCompact bool graphBox bool graphAll bool ) var graphCmd = &cobra.Command{ Use: "graph [issue-id]", GroupID: "deps", Short: "Display issue dependency graph", Long: `Display a 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. With --all, shows all open issues grouped by connected component. Display formats: --box (default) ASCII boxes showing layers, more detailed --compact Tree format, one line per issue, more scannable The graph shows execution order: - Layer 0 / leftmost = no dependencies (can start immediately) - Higher layers depend on lower layers - Nodes in the same layer can run in parallel Status icons: ○ open ◐ in_progress ● blocked ✓ closed ❄ deferred`, Args: cobra.RangeArgs(0, 1), Run: func(cmd *cobra.Command, args []string) { ctx := rootCtx // Validate args if graphAll && len(args) > 0 { fmt.Fprintf(os.Stderr, "Error: cannot specify issue ID with --all flag\n") os.Exit(1) } if !graphAll && len(args) == 0 { fmt.Fprintf(os.Stderr, "Error: issue ID required (or use --all for all open issues)\n") os.Exit(1) } // If daemon is running but doesn't support this command, use direct storage if daemonClient != nil && store == nil { var err error store, err = sqlite.New(ctx, dbPath) if err != nil { fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err) os.Exit(1) } defer func() { _ = store.Close() }() } if store == nil { fmt.Fprintf(os.Stderr, "Error: no database connection\n") os.Exit(1) } // Handle --all flag: show graph for all open issues if graphAll { subgraphs, err := loadAllGraphSubgraphs(ctx, store) if err != nil { fmt.Fprintf(os.Stderr, "Error loading all issues: %v\n", err) os.Exit(1) } if len(subgraphs) == 0 { fmt.Println("No open issues found") return } if jsonOutput { outputJSON(subgraphs) return } // Render all subgraphs for i, subgraph := range subgraphs { layout := computeLayout(subgraph) if graphCompact { renderGraphCompact(layout, subgraph) } else { renderGraph(layout, subgraph) } if i < len(subgraphs)-1 { fmt.Println(strings.Repeat("─", 60)) } } return } // Single issue mode var issueID string 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 { 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) } } // 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 graph - compact tree format or box format (default) if graphCompact { renderGraphCompact(layout, subgraph) } else { renderGraph(layout, subgraph) } }, } func init() { graphCmd.Flags().BoolVar(&graphAll, "all", false, "Show graph for all open issues") graphCmd.Flags().BoolVar(&graphCompact, "compact", false, "Tree format, one line per issue, more scannable") graphCmd.Flags().BoolVar(&graphBox, "box", true, "ASCII boxes showing layers (default)") graphCmd.ValidArgsFunction = issueIDCompletion rootCmd.AddCommand(graphCmd) } // loadGraphSubgraph loads an issue and its subgraph for visualization // Unlike template loading, this includes ALL dependency types (not just parent-child) func loadGraphSubgraph(ctx context.Context, s storage.Storage, issueID string) (*TemplateSubgraph, error) { if s == nil { return nil, fmt.Errorf("no database connection") } // Get the root issue root, err := s.GetIssue(ctx, issueID) if err != nil { return nil, fmt.Errorf("failed to get issue: %w", err) } if root == nil { return nil, fmt.Errorf("issue %s not found", issueID) } subgraph := &TemplateSubgraph{ Root: root, Issues: []*types.Issue{root}, IssueMap: map[string]*types.Issue{root.ID: root}, } // BFS to find all connected issues (via any dependency type) // We traverse both directions: dependents and dependencies queue := []string{root.ID} visited := map[string]bool{root.ID: true} for len(queue) > 0 { currentID := queue[0] queue = queue[1:] // Get issues that depend on this one (dependents) dependents, err := s.GetDependents(ctx, currentID) if err != nil { continue } for _, dep := range dependents { if !visited[dep.ID] { visited[dep.ID] = true subgraph.Issues = append(subgraph.Issues, dep) subgraph.IssueMap[dep.ID] = dep queue = append(queue, dep.ID) } } // Get issues this one depends on (dependencies) - but only for non-root // to avoid pulling in unrelated upstream issues if currentID == root.ID { // For root, we might want to include direct dependencies too // Skip for now to keep graph focused on "what this issue encompasses" } } // Load all dependencies within the subgraph for _, issue := range subgraph.Issues { deps, err := s.GetDependencyRecords(ctx, issue.ID) if err != nil { continue } for _, dep := range deps { // Only include dependencies where both ends are in the subgraph if _, ok := subgraph.IssueMap[dep.DependsOnID]; ok { subgraph.Dependencies = append(subgraph.Dependencies, dep) } } } return subgraph, nil } // loadAllGraphSubgraphs loads all open issues and groups them by connected component // Each component is a subgraph of issues that share dependencies func loadAllGraphSubgraphs(ctx context.Context, s storage.Storage) ([]*TemplateSubgraph, error) { if s == nil { return nil, fmt.Errorf("no database connection") } // Get all open issues (open, in_progress, blocked) // We need to make multiple calls since IssueFilter takes a single status var allIssues []*types.Issue for _, status := range []types.Status{types.StatusOpen, types.StatusInProgress, types.StatusBlocked} { statusCopy := status issues, err := s.SearchIssues(ctx, "", types.IssueFilter{ Status: &statusCopy, }) if err != nil { return nil, fmt.Errorf("failed to search issues: %w", err) } allIssues = append(allIssues, issues...) } if len(allIssues) == 0 { return nil, nil } // Build issue map issueMap := make(map[string]*types.Issue) for _, issue := range allIssues { issueMap[issue.ID] = issue } // Load all dependencies between these issues allDeps := make([]*types.Dependency, 0) for _, issue := range allIssues { deps, err := s.GetDependencyRecords(ctx, issue.ID) if err != nil { continue } for _, dep := range deps { // Only include deps where both ends are in our issue set if _, ok := issueMap[dep.DependsOnID]; ok { allDeps = append(allDeps, dep) } } } // Build adjacency list for union-find adj := make(map[string][]string) for _, dep := range allDeps { adj[dep.IssueID] = append(adj[dep.IssueID], dep.DependsOnID) adj[dep.DependsOnID] = append(adj[dep.DependsOnID], dep.IssueID) } // Find connected components using BFS visited := make(map[string]bool) var components [][]string for _, issue := range allIssues { if visited[issue.ID] { continue } // BFS to find all connected issues var component []string queue := []string{issue.ID} visited[issue.ID] = true for len(queue) > 0 { current := queue[0] queue = queue[1:] component = append(component, current) for _, neighbor := range adj[current] { if !visited[neighbor] { visited[neighbor] = true queue = append(queue, neighbor) } } } components = append(components, component) } // Sort components by size (largest first) and then by priority of first issue sort.Slice(components, func(i, j int) bool { // First by size (descending) if len(components[i]) != len(components[j]) { return len(components[i]) > len(components[j]) } // Then by priority of first issue (ascending = higher priority first) issueI := issueMap[components[i][0]] issueJ := issueMap[components[j][0]] return issueI.Priority < issueJ.Priority }) // Create subgraph for each component var subgraphs []*TemplateSubgraph for _, component := range components { if len(component) == 0 { continue } // Find the best "root" for this component // Prefer: epics > highest priority > oldest var root *types.Issue for _, id := range component { issue := issueMap[id] if root == nil { root = issue continue } // Prefer epics if issue.IssueType == types.TypeEpic && root.IssueType != types.TypeEpic { root = issue continue } if root.IssueType == types.TypeEpic && issue.IssueType != types.TypeEpic { continue } // Prefer higher priority (lower number) if issue.Priority < root.Priority { root = issue } } subgraph := &TemplateSubgraph{ Root: root, IssueMap: make(map[string]*types.Issue), } for _, id := range component { issue := issueMap[id] subgraph.Issues = append(subgraph.Issues, issue) subgraph.IssueMap[id] = issue } // Add dependencies for this component for _, dep := range allDeps { if _, inComponent := subgraph.IssueMap[dep.IssueID]; inComponent { if _, depInComponent := subgraph.IssueMap[dep.DependsOnID]; depInComponent { subgraph.Dependencies = append(subgraph.Dependencies, dep) } } } subgraphs = append(subgraphs, subgraph) } return subgraphs, nil } // 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 } fmt.Printf("\n%s Dependency graph for %s:\n\n", ui.RenderAccent("📊"), 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() // Build dependency counts from subgraph blocksCounts, blockedByCounts := computeDependencyCounts(subgraph) // 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 := renderNodeBoxWithDeps(node, boxWidth, blocksCounts[id], blockedByCounts[id]) 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 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)) } // renderGraphCompact renders the graph in compact tree format // One line per issue, more scannable, uses tree connectors (├──, └──, │) func renderGraphCompact(layout *GraphLayout, subgraph *TemplateSubgraph) { if len(layout.Nodes) == 0 { fmt.Println("Empty graph") return } fmt.Printf("\n%s Dependency graph for %s (%d issues, %d layers)\n\n", ui.RenderAccent("📊"), layout.RootID, len(layout.Nodes), len(layout.Layers)) // Legend fmt.Println(" Status: ○ open ◐ in_progress ● blocked ✓ closed ❄ deferred") fmt.Println() // Build parent-child map from subgraph dependencies children := make(map[string][]string) // parent -> children childSet := make(map[string]bool) // track which issues are children for _, dep := range subgraph.Dependencies { if dep.Type == types.DepParentChild { children[dep.DependsOnID] = append(children[dep.DependsOnID], dep.IssueID) childSet[dep.IssueID] = true } } // Sort children by priority then ID for consistent output for parentID := range children { sort.Slice(children[parentID], func(i, j int) bool { nodeI := layout.Nodes[children[parentID][i]] nodeJ := layout.Nodes[children[parentID][j]] if nodeI.Issue.Priority != nodeJ.Issue.Priority { return nodeI.Issue.Priority < nodeJ.Issue.Priority } return nodeI.Issue.ID < nodeJ.Issue.ID }) } // Render by layer with tree structure for layerIdx, layer := range layout.Layers { // Layer header layerHeader := fmt.Sprintf("LAYER %d", layerIdx) if layerIdx == 0 { layerHeader += " (ready)" } fmt.Printf(" %s\n", ui.RenderAccent(layerHeader)) for i, id := range layer { node := layout.Nodes[id] isLast := i == len(layer)-1 // Format node line line := formatCompactNode(node) // Tree connector connector := "├── " if isLast { connector = "└── " } fmt.Printf(" %s%s\n", connector, line) // Render children (if this issue has children in the subgraph) if childIDs, ok := children[id]; ok && len(childIDs) > 0 { childPrefix := "│ " if isLast { childPrefix = " " } renderCompactChildren(layout, childIDs, children, childPrefix, 1) } } fmt.Println() } } // renderCompactChildren recursively renders children in tree format func renderCompactChildren(layout *GraphLayout, childIDs []string, children map[string][]string, prefix string, depth int) { for i, childID := range childIDs { node := layout.Nodes[childID] if node == nil { continue } isLast := i == len(childIDs)-1 connector := "├── " if isLast { connector = "└── " } line := formatCompactNode(node) fmt.Printf(" %s%s%s\n", prefix, connector, line) // Recurse for nested children if grandchildren, ok := children[childID]; ok && len(grandchildren) > 0 { childPrefix := prefix if isLast { childPrefix += " " } else { childPrefix += "│ " } renderCompactChildren(layout, grandchildren, children, childPrefix, depth+1) } } } // formatCompactNode formats a single node for compact output // Format: STATUS_ICON ID PRIORITY Title func formatCompactNode(node *GraphNode) string { status := string(node.Issue.Status) // Use shared status icon with semantic color statusIcon := ui.RenderStatusIcon(status) // Priority with icon priorityTag := ui.RenderPriority(node.Issue.Priority) // Title - truncate if too long title := truncateTitle(node.Issue.Title, 50) // Build line - apply status style to entire line for closed issues style := ui.GetStatusStyle(status) if node.Issue.Status == types.StatusClosed { return fmt.Sprintf("%s %s %s %s", statusIcon, style.Render(node.Issue.ID), style.Render(fmt.Sprintf("● P%d", node.Issue.Priority)), style.Render(title)) } return fmt.Sprintf("%s %s %s %s", statusIcon, node.Issue.ID, priorityTag, title) } // renderNodeBox renders a single node as an ASCII box // Uses semantic status styles from ui package for consistency func renderNodeBox(node *GraphNode, width int) string { title := truncateTitle(node.Issue.Title, width-4) paddedTitle := padRight(title, width-4) status := string(node.Issue.Status) // Use shared status icon and style statusIcon := ui.RenderStatusIcon(status) style := ui.GetStatusStyle(status) // Apply style to title for actionable statuses var titleStr string if node.Issue.Status == types.StatusOpen { titleStr = paddedTitle // no color for open - available but not urgent } else { titleStr = style.Render(paddedTitle) } id := node.Issue.ID // Build the box topBottom := " ┌" + strings.Repeat("─", width) + "┐" middle := fmt.Sprintf(" │ %s %s │", statusIcon, titleStr) idLine := fmt.Sprintf(" │ %s │", ui.RenderMuted(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)) } // computeDependencyCounts calculates how many issues each issue blocks and is blocked by // Excludes parent-child relationships and the root issue from counts to reduce cognitive noise func computeDependencyCounts(subgraph *TemplateSubgraph) (blocks map[string]int, blockedBy map[string]int) { blocks = make(map[string]int) blockedBy = make(map[string]int) if subgraph == nil { return blocks, blockedBy } rootID := "" if subgraph.Root != nil { rootID = subgraph.Root.ID } for _, dep := range subgraph.Dependencies { // Only count "blocks" dependencies (not parent-child, related, etc.) if dep.Type != types.DepBlocks { continue } // Skip if the blocker is the root issue - this is obvious from graph structure // and showing "needs:1" when it's just the parent epic is cognitive noise if dep.DependsOnID == rootID { continue } // 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 // Uses semantic status styles from ui package for consistency across commands // Design principle: only actionable states get color, closed items fade func renderNodeBoxWithDeps(node *GraphNode, width int, blocksCount int, blockedByCount int) string { title := truncateTitle(node.Issue.Title, width-4) paddedTitle := padRight(title, width-4) status := string(node.Issue.Status) // Use shared status icon and style from ui package statusIcon := ui.RenderStatusIcon(status) style := ui.GetStatusStyle(status) // Apply style to title for actionable statuses var titleStr string if node.Issue.Status == types.StatusOpen { titleStr = paddedTitle // no color for open - available but not urgent } else { titleStr = style.Render(paddedTitle) } id := node.Issue.ID // Build dependency info string - only show if meaningful counts exist // Note: we build the plain text version first for padding, then apply colors var depInfoPlain string var depInfoStyled string if blocksCount > 0 || blockedByCount > 0 { plainParts := []string{} styledParts := []string{} if blocksCount > 0 { plainText := fmt.Sprintf("blocks:%d", blocksCount) plainParts = append(plainParts, plainText) // Use semantic color for blocks indicator - attention-grabbing styledParts = append(styledParts, ui.StatusBlockedStyle.Render(plainText)) } if blockedByCount > 0 { plainText := fmt.Sprintf("needs:%d", blockedByCount) plainParts = append(plainParts, plainText) // Use muted color for needs indicator - informational styledParts = append(styledParts, ui.MutedStyle.Render(plainText)) } depInfoPlain = strings.Join(plainParts, " ") depInfoStyled = strings.Join(styledParts, " ") } // Build the box topBottom := " ┌" + strings.Repeat("─", width) + "┐" middle := fmt.Sprintf(" │ %s %s │", statusIcon, titleStr) idLine := fmt.Sprintf(" │ %s │", ui.RenderMuted(padRight(id, width-2))) var result string if depInfoPlain != "" { // Pad based on plain text length, then render with styled version padding := width - 2 - len([]rune(depInfoPlain)) if padding < 0 { padding = 0 } depLine := fmt.Sprintf(" │ %s%s │", depInfoStyled, strings.Repeat(" ", padding)) 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 }