Analysis found these commands are dead code: - gt never calls `bd pin` - uses `bd update --status=pinned` instead - Beads.Pin() wrapper exists but is never called - bd hook functionality duplicated by gt mol status - Code comment says "pinned field is cosmetic for bd hook visibility" Removed: - cmd/bd/pin.go - cmd/bd/unpin.go - cmd/bd/hook.go 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
522 lines
14 KiB
Go
522 lines
14 KiB
Go
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 graphCmd = &cobra.Command{
|
|
Use: "graph <issue-id>",
|
|
GroupID: "deps",
|
|
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)
|
|
}
|
|
|
|
// 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() }()
|
|
}
|
|
|
|
// 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
|
|
// 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
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
|
|
// renderNodeBox renders a single node as an ASCII box
|
|
func renderNodeBox(node *GraphNode, width int) string {
|
|
// Status indicator
|
|
var statusIcon string
|
|
var titleStr string
|
|
|
|
title := truncateTitle(node.Issue.Title, width-4)
|
|
|
|
switch node.Issue.Status {
|
|
case types.StatusOpen:
|
|
statusIcon = "○"
|
|
titleStr = padRight(title, width-4)
|
|
case types.StatusInProgress:
|
|
statusIcon = "◐"
|
|
titleStr = ui.RenderWarn(padRight(title, width-4))
|
|
case types.StatusBlocked:
|
|
statusIcon = "●"
|
|
titleStr = ui.RenderFail(padRight(title, width-4))
|
|
case types.StatusDeferred:
|
|
statusIcon = "❄"
|
|
titleStr = ui.RenderAccent(padRight(title, width-4))
|
|
case types.StatusClosed:
|
|
statusIcon = "✓"
|
|
titleStr = ui.RenderPass(padRight(title, width-4))
|
|
default:
|
|
statusIcon = "?"
|
|
titleStr = padRight(title, width-4)
|
|
}
|
|
|
|
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
|
|
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
|
|
}
|
|
|
|
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 titleStr string
|
|
|
|
title := truncateTitle(node.Issue.Title, width-4)
|
|
|
|
switch node.Issue.Status {
|
|
case types.StatusOpen:
|
|
statusIcon = "○"
|
|
titleStr = padRight(title, width-4)
|
|
case types.StatusInProgress:
|
|
statusIcon = "◐"
|
|
titleStr = ui.RenderWarn(padRight(title, width-4))
|
|
case types.StatusBlocked:
|
|
statusIcon = "●"
|
|
titleStr = ui.RenderFail(padRight(title, width-4))
|
|
case types.StatusDeferred:
|
|
statusIcon = "❄"
|
|
titleStr = ui.RenderAccent(padRight(title, width-4))
|
|
case types.StatusClosed:
|
|
statusIcon = "✓"
|
|
titleStr = ui.RenderPass(padRight(title, width-4))
|
|
default:
|
|
statusIcon = "?"
|
|
titleStr = padRight(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, titleStr)
|
|
idLine := fmt.Sprintf(" │ %s │", ui.RenderMuted(padRight(id, width-2)))
|
|
|
|
var result string
|
|
if depInfo != "" {
|
|
depLine := fmt.Sprintf(" │ %s │", ui.RenderAccent(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
|
|
}
|