|
| 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