759 lines
21 KiB
Go
759 lines
21 KiB
Go
// Package main implements the bd CLI dependency management commands.
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads/internal/rpc"
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
"github.com/steveyegge/beads/internal/utils"
|
|
)
|
|
|
|
var depCmd = &cobra.Command{
|
|
Use: "dep",
|
|
Short: "Manage dependencies",
|
|
}
|
|
|
|
var depAddCmd = &cobra.Command{
|
|
Use: "add [issue-id] [depends-on-id]",
|
|
Short: "Add a dependency",
|
|
Args: cobra.ExactArgs(2),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
depType, _ := cmd.Flags().GetString("type")
|
|
|
|
ctx := rootCtx
|
|
|
|
// Resolve partial IDs first
|
|
var fromID, toID string
|
|
if daemonClient != nil {
|
|
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]}
|
|
resp, err := daemonClient.ResolveID(resolveArgs)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err)
|
|
os.Exit(1)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &fromID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
resolveArgs = &rpc.ResolveIDArgs{ID: args[1]}
|
|
resp, err = daemonClient.ResolveID(resolveArgs)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err)
|
|
os.Exit(1)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &toID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
var err error
|
|
fromID, err = utils.ResolvePartialID(ctx, store, args[0])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
toID, err = utils.ResolvePartialID(ctx, store, args[1])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// If daemon is running, use RPC
|
|
if daemonClient != nil {
|
|
depArgs := &rpc.DepAddArgs{
|
|
FromID: fromID,
|
|
ToID: toID,
|
|
DepType: depType,
|
|
}
|
|
|
|
resp, err := daemonClient.AddDependency(depArgs)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if jsonOutput {
|
|
fmt.Println(string(resp.Data))
|
|
return
|
|
}
|
|
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
fmt.Printf("%s Added dependency: %s depends on %s (%s)\n",
|
|
green("✓"), args[0], args[1], depType)
|
|
return
|
|
}
|
|
|
|
// Direct mode
|
|
dep := &types.Dependency{
|
|
IssueID: fromID,
|
|
DependsOnID: toID,
|
|
Type: types.DependencyType(depType),
|
|
}
|
|
|
|
if err := store.AddDependency(ctx, dep, actor); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Schedule auto-flush
|
|
markDirtyAndScheduleFlush()
|
|
|
|
// Check for cycles after adding dependency
|
|
cycles, err := store.DetectCycles(ctx)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: Failed to check for cycles: %v\n", err)
|
|
} else if len(cycles) > 0 {
|
|
yellow := color.New(color.FgYellow).SprintFunc()
|
|
fmt.Fprintf(os.Stderr, "\n%s Warning: Dependency cycle detected!\n", yellow("⚠"))
|
|
fmt.Fprintf(os.Stderr, "This can hide issues from the ready work list and cause confusion.\n\n")
|
|
fmt.Fprintf(os.Stderr, "Cycle path:\n")
|
|
for _, cycle := range cycles {
|
|
for j, issue := range cycle {
|
|
if j == 0 {
|
|
fmt.Fprintf(os.Stderr, " %s", issue.ID)
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, " → %s", issue.ID)
|
|
}
|
|
}
|
|
if len(cycle) > 0 {
|
|
fmt.Fprintf(os.Stderr, " → %s", cycle[0].ID)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "\n")
|
|
}
|
|
fmt.Fprintf(os.Stderr, "\nRun 'bd dep cycles' for detailed analysis.\n\n")
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(map[string]interface{}{
|
|
"status": "added",
|
|
"issue_id": fromID,
|
|
"depends_on_id": toID,
|
|
"type": depType,
|
|
})
|
|
return
|
|
}
|
|
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
fmt.Printf("%s Added dependency: %s depends on %s (%s)\n",
|
|
green("✓"), fromID, toID, depType)
|
|
},
|
|
}
|
|
|
|
var depRemoveCmd = &cobra.Command{
|
|
Use: "remove [issue-id] [depends-on-id]",
|
|
Short: "Remove a dependency",
|
|
Args: cobra.ExactArgs(2),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
ctx := rootCtx
|
|
|
|
// Resolve partial IDs first
|
|
var fromID, toID string
|
|
if daemonClient != nil {
|
|
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]}
|
|
resp, err := daemonClient.ResolveID(resolveArgs)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err)
|
|
os.Exit(1)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &fromID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
resolveArgs = &rpc.ResolveIDArgs{ID: args[1]}
|
|
resp, err = daemonClient.ResolveID(resolveArgs)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err)
|
|
os.Exit(1)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &toID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
var err error
|
|
fromID, err = utils.ResolvePartialID(ctx, store, args[0])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
toID, err = utils.ResolvePartialID(ctx, store, args[1])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// If daemon is running, use RPC
|
|
if daemonClient != nil {
|
|
depArgs := &rpc.DepRemoveArgs{
|
|
FromID: fromID,
|
|
ToID: toID,
|
|
}
|
|
|
|
resp, err := daemonClient.RemoveDependency(depArgs)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if jsonOutput {
|
|
fmt.Println(string(resp.Data))
|
|
return
|
|
}
|
|
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
fmt.Printf("%s Removed dependency: %s no longer depends on %s\n",
|
|
green("✓"), fromID, toID)
|
|
return
|
|
}
|
|
|
|
// Direct mode
|
|
fullFromID := fromID
|
|
fullToID := toID
|
|
|
|
if err := store.RemoveDependency(ctx, fullFromID, fullToID, actor); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Schedule auto-flush
|
|
markDirtyAndScheduleFlush()
|
|
|
|
if jsonOutput {
|
|
outputJSON(map[string]interface{}{
|
|
"status": "removed",
|
|
"issue_id": fullFromID,
|
|
"depends_on_id": fullToID,
|
|
})
|
|
return
|
|
}
|
|
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
fmt.Printf("%s Removed dependency: %s no longer depends on %s\n",
|
|
green("✓"), fullFromID, fullToID)
|
|
},
|
|
}
|
|
|
|
var depTreeCmd = &cobra.Command{
|
|
Use: "tree [issue-id]",
|
|
Short: "Show dependency tree",
|
|
Long: `Show dependency tree rooted at the given issue.
|
|
|
|
By default, shows dependencies (what blocks this issue). Use --direction to control:
|
|
- down: Show dependencies (what blocks this issue) - default
|
|
- up: Show dependents (what this issue blocks)
|
|
- both: Show full graph in both directions
|
|
|
|
Examples:
|
|
bd dep tree gt-0iqq # Show what blocks gt-0iqq
|
|
bd dep tree gt-0iqq --direction=up # Show what gt-0iqq blocks
|
|
bd dep tree gt-0iqq --status=open # Only show open issues
|
|
bd dep tree gt-0iqq --depth=3 # Limit to 3 levels deep`,
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
ctx := rootCtx
|
|
|
|
// Resolve partial ID first
|
|
var fullID string
|
|
if daemonClient != nil {
|
|
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]}
|
|
resp, err := daemonClient.ResolveID(resolveArgs)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err)
|
|
os.Exit(1)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &fullID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
var err error
|
|
fullID, err = utils.ResolvePartialID(ctx, store, args[0])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", args[0], err)
|
|
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(rootCtx, dbPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = store.Close() }()
|
|
}
|
|
|
|
showAllPaths, _ := cmd.Flags().GetBool("show-all-paths")
|
|
maxDepth, _ := cmd.Flags().GetInt("max-depth")
|
|
reverse, _ := cmd.Flags().GetBool("reverse")
|
|
direction, _ := cmd.Flags().GetString("direction")
|
|
statusFilter, _ := cmd.Flags().GetString("status")
|
|
formatStr, _ := cmd.Flags().GetString("format")
|
|
|
|
// Handle --direction flag (takes precedence over deprecated --reverse)
|
|
if direction == "" && reverse {
|
|
direction = "up"
|
|
} else if direction == "" {
|
|
direction = "down"
|
|
}
|
|
|
|
// Validate direction
|
|
if direction != "down" && direction != "up" && direction != "both" {
|
|
fmt.Fprintf(os.Stderr, "Error: --direction must be 'down', 'up', or 'both'\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
if maxDepth < 1 {
|
|
fmt.Fprintf(os.Stderr, "Error: --max-depth must be >= 1\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// For "both" direction, we need to fetch both trees and merge them
|
|
var tree []*types.TreeNode
|
|
var err error
|
|
|
|
if direction == "both" {
|
|
// Get dependencies (down) - what blocks this issue
|
|
downTree, err := store.GetDependencyTree(ctx, fullID, maxDepth, showAllPaths, false)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Get dependents (up) - what this issue blocks
|
|
upTree, err := store.GetDependencyTree(ctx, fullID, maxDepth, showAllPaths, true)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Merge: root appears once, dependencies below, dependents above
|
|
// We'll show dependents first (with negative-like positioning conceptually),
|
|
// then root, then dependencies
|
|
tree = mergeBidirectionalTrees(downTree, upTree, fullID)
|
|
} else {
|
|
tree, err = store.GetDependencyTree(ctx, fullID, maxDepth, showAllPaths, direction == "up")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Apply status filter if specified
|
|
if statusFilter != "" {
|
|
tree = filterTreeByStatus(tree, types.Status(statusFilter))
|
|
}
|
|
|
|
// Handle mermaid format
|
|
if formatStr == "mermaid" {
|
|
outputMermaidTree(tree, args[0])
|
|
return
|
|
}
|
|
|
|
if jsonOutput {
|
|
// Always output array, even if empty
|
|
if tree == nil {
|
|
tree = []*types.TreeNode{}
|
|
}
|
|
outputJSON(tree)
|
|
return
|
|
}
|
|
|
|
if len(tree) == 0 {
|
|
switch direction {
|
|
case "up":
|
|
fmt.Printf("\n%s has no dependents\n", fullID)
|
|
case "both":
|
|
fmt.Printf("\n%s has no dependencies or dependents\n", fullID)
|
|
default:
|
|
fmt.Printf("\n%s has no dependencies\n", fullID)
|
|
}
|
|
return
|
|
}
|
|
|
|
cyan := color.New(color.FgCyan).SprintFunc()
|
|
switch direction {
|
|
case "up":
|
|
fmt.Printf("\n%s Dependent tree for %s:\n\n", cyan("🌲"), fullID)
|
|
case "both":
|
|
fmt.Printf("\n%s Full dependency graph for %s:\n\n", cyan("🌲"), fullID)
|
|
default:
|
|
fmt.Printf("\n%s Dependency tree for %s:\n\n", cyan("🌲"), fullID)
|
|
}
|
|
|
|
// Render tree with proper connectors
|
|
renderTree(tree, maxDepth, direction)
|
|
fmt.Println()
|
|
},
|
|
}
|
|
|
|
var depCyclesCmd = &cobra.Command{
|
|
Use: "cycles",
|
|
Short: "Detect dependency cycles",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
// 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(rootCtx, dbPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer func() { _ = store.Close() }()
|
|
}
|
|
|
|
ctx := rootCtx
|
|
cycles, err := store.DetectCycles(ctx)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if jsonOutput {
|
|
// Always output array, even if empty
|
|
if cycles == nil {
|
|
cycles = [][]*types.Issue{}
|
|
}
|
|
outputJSON(cycles)
|
|
return
|
|
}
|
|
|
|
if len(cycles) == 0 {
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
fmt.Printf("\n%s No dependency cycles detected\n\n", green("✓"))
|
|
return
|
|
}
|
|
|
|
red := color.New(color.FgRed).SprintFunc()
|
|
fmt.Printf("\n%s Found %d dependency cycles:\n\n", red("⚠"), len(cycles))
|
|
for i, cycle := range cycles {
|
|
fmt.Printf("%d. Cycle involving:\n", i+1)
|
|
for _, issue := range cycle {
|
|
fmt.Printf(" - %s: %s\n", issue.ID, issue.Title)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
},
|
|
}
|
|
|
|
// outputMermaidTree outputs a dependency tree in Mermaid.js flowchart format
|
|
func outputMermaidTree(tree []*types.TreeNode, rootID string) {
|
|
if len(tree) == 0 {
|
|
fmt.Println("flowchart TD")
|
|
fmt.Printf(" %s[\"No dependencies\"]\n", rootID)
|
|
return
|
|
}
|
|
|
|
fmt.Println("flowchart TD")
|
|
|
|
// Output nodes
|
|
nodesSeen := make(map[string]bool)
|
|
for _, node := range tree {
|
|
if !nodesSeen[node.ID] {
|
|
emoji := getStatusEmoji(node.Status)
|
|
label := fmt.Sprintf("%s %s: %s", emoji, node.ID, node.Title)
|
|
// Escape quotes and backslashes in label
|
|
label = strings.ReplaceAll(label, "\\", "\\\\")
|
|
label = strings.ReplaceAll(label, "\"", "\\\"")
|
|
fmt.Printf(" %s[\"%s\"]\n", node.ID, label)
|
|
|
|
nodesSeen[node.ID] = true
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
|
|
// Output edges - use explicit parent relationships from ParentID
|
|
for _, node := range tree {
|
|
if node.ParentID != "" && node.ParentID != node.ID {
|
|
fmt.Printf(" %s --> %s\n", node.ParentID, node.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// getStatusEmoji returns a symbol indicator for a given status
|
|
func getStatusEmoji(status types.Status) string {
|
|
switch status {
|
|
case types.StatusOpen:
|
|
return "☐" // U+2610 Ballot Box
|
|
case types.StatusInProgress:
|
|
return "◧" // U+25E7 Square Left Half Black
|
|
case types.StatusBlocked:
|
|
return "⚠" // U+26A0 Warning Sign
|
|
case types.StatusClosed:
|
|
return "☑" // U+2611 Ballot Box with Check
|
|
default:
|
|
return "?"
|
|
}
|
|
}
|
|
|
|
// treeRenderer holds state for rendering a tree with proper connectors
|
|
type treeRenderer struct {
|
|
// Track which nodes we've already displayed (for "shown above" handling)
|
|
seen map[string]bool
|
|
// Track connector state at each depth level (true = has more siblings)
|
|
activeConnectors []bool
|
|
// Maximum depth reached
|
|
maxDepth int
|
|
// Direction of traversal
|
|
direction string
|
|
}
|
|
|
|
// renderTree renders the tree with proper box-drawing connectors
|
|
func renderTree(tree []*types.TreeNode, maxDepth int, direction string) {
|
|
if len(tree) == 0 {
|
|
return
|
|
}
|
|
|
|
r := &treeRenderer{
|
|
seen: make(map[string]bool),
|
|
activeConnectors: make([]bool, maxDepth+1),
|
|
maxDepth: maxDepth,
|
|
direction: direction,
|
|
}
|
|
|
|
// Build a map of parent -> children for proper sibling tracking
|
|
children := make(map[string][]*types.TreeNode)
|
|
var root *types.TreeNode
|
|
|
|
for _, node := range tree {
|
|
if node.Depth == 0 {
|
|
root = node
|
|
} else {
|
|
children[node.ParentID] = append(children[node.ParentID], node)
|
|
}
|
|
}
|
|
|
|
if root == nil && len(tree) > 0 {
|
|
root = tree[0]
|
|
}
|
|
|
|
// Render recursively from root
|
|
r.renderNode(root, children, 0, true)
|
|
}
|
|
|
|
// renderNode renders a single node and its children
|
|
func (r *treeRenderer) renderNode(node *types.TreeNode, children map[string][]*types.TreeNode, depth int, isLast bool) {
|
|
if node == nil {
|
|
return
|
|
}
|
|
|
|
// Build the prefix with connectors
|
|
var prefix strings.Builder
|
|
|
|
// Add vertical lines for active parent connectors
|
|
for i := 0; i < depth; i++ {
|
|
if r.activeConnectors[i] {
|
|
prefix.WriteString("│ ")
|
|
} else {
|
|
prefix.WriteString(" ")
|
|
}
|
|
}
|
|
|
|
// Add the branch connector for non-root nodes
|
|
if depth > 0 {
|
|
if isLast {
|
|
prefix.WriteString("└── ")
|
|
} else {
|
|
prefix.WriteString("├── ")
|
|
}
|
|
}
|
|
|
|
// Check if we've seen this node before (diamond dependency)
|
|
if r.seen[node.ID] {
|
|
gray := color.New(color.FgHiBlack).SprintFunc()
|
|
fmt.Printf("%s%s (shown above)\n", prefix.String(), gray(node.ID))
|
|
return
|
|
}
|
|
r.seen[node.ID] = true
|
|
|
|
// Format the node line
|
|
line := formatTreeNode(node)
|
|
|
|
// Add truncation warning if at max depth and has children
|
|
if node.Truncated || (depth == r.maxDepth && len(children[node.ID]) > 0) {
|
|
yellow := color.New(color.FgYellow).SprintFunc()
|
|
line += yellow(" …")
|
|
}
|
|
|
|
fmt.Printf("%s%s\n", prefix.String(), line)
|
|
|
|
// Render children
|
|
nodeChildren := children[node.ID]
|
|
for i, child := range nodeChildren {
|
|
// Update connector state for this depth
|
|
// For depth 0 (root level), never show vertical connector since root has no siblings
|
|
if depth > 0 {
|
|
r.activeConnectors[depth] = (i < len(nodeChildren)-1)
|
|
}
|
|
r.renderNode(child, children, depth+1, i == len(nodeChildren)-1)
|
|
}
|
|
}
|
|
|
|
// formatTreeNode formats a single tree node with status, ready indicator, etc.
|
|
func formatTreeNode(node *types.TreeNode) string {
|
|
// Color the ID based on status
|
|
var idStr string
|
|
switch node.Status {
|
|
case types.StatusOpen:
|
|
idStr = color.New(color.FgWhite).Sprint(node.ID)
|
|
case types.StatusInProgress:
|
|
idStr = color.New(color.FgYellow).Sprint(node.ID)
|
|
case types.StatusBlocked:
|
|
idStr = color.New(color.FgRed).Sprint(node.ID)
|
|
case types.StatusClosed:
|
|
idStr = color.New(color.FgGreen).Sprint(node.ID)
|
|
default:
|
|
idStr = node.ID
|
|
}
|
|
|
|
// Build the line
|
|
line := fmt.Sprintf("%s: %s [P%d] (%s)",
|
|
idStr, node.Title, node.Priority, node.Status)
|
|
|
|
// Add READY indicator for open issues (those that could be worked on)
|
|
// An issue is ready if it's open and has no blocking dependencies
|
|
// (In the tree view, depth 0 with status open implies ready in the "down" direction)
|
|
if node.Status == types.StatusOpen && node.Depth == 0 {
|
|
green := color.New(color.FgGreen, color.Bold).SprintFunc()
|
|
line += " " + green("[READY]")
|
|
}
|
|
|
|
return line
|
|
}
|
|
|
|
// filterTreeByStatus filters the tree to only include nodes with the given status
|
|
// Note: keeps parent chain to maintain tree structure
|
|
func filterTreeByStatus(tree []*types.TreeNode, status types.Status) []*types.TreeNode {
|
|
if len(tree) == 0 {
|
|
return tree
|
|
}
|
|
|
|
// First pass: identify which nodes match the status
|
|
matches := make(map[string]bool)
|
|
for _, node := range tree {
|
|
if node.Status == status {
|
|
matches[node.ID] = true
|
|
}
|
|
}
|
|
|
|
// If no matches, return empty
|
|
if len(matches) == 0 {
|
|
return []*types.TreeNode{}
|
|
}
|
|
|
|
// Second pass: keep matching nodes and their ancestors
|
|
// Build parent map
|
|
parentOf := make(map[string]string)
|
|
for _, node := range tree {
|
|
if node.ParentID != "" && node.ParentID != node.ID {
|
|
parentOf[node.ID] = node.ParentID
|
|
}
|
|
}
|
|
|
|
// Mark all ancestors of matching nodes
|
|
keep := make(map[string]bool)
|
|
for id := range matches {
|
|
keep[id] = true
|
|
// Walk up to root
|
|
current := id
|
|
for {
|
|
parent, ok := parentOf[current]
|
|
if !ok || parent == current {
|
|
break
|
|
}
|
|
keep[parent] = true
|
|
current = parent
|
|
}
|
|
}
|
|
|
|
// Filter the tree
|
|
var filtered []*types.TreeNode
|
|
for _, node := range tree {
|
|
if keep[node.ID] {
|
|
filtered = append(filtered, node)
|
|
}
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
// mergeBidirectionalTrees merges up and down trees into a single visualization
|
|
// The root appears once, with dependencies shown below and dependents shown above
|
|
func mergeBidirectionalTrees(downTree, upTree []*types.TreeNode, rootID string) []*types.TreeNode {
|
|
// For bidirectional display, we show the down tree (dependencies) as the main tree
|
|
// and add a visual separator with the up tree (dependents)
|
|
//
|
|
// For simplicity, we'll just return the down tree for now
|
|
// A more sophisticated implementation would show both with visual separation
|
|
|
|
// Find root in each tree
|
|
var result []*types.TreeNode
|
|
|
|
// Add dependents section if any (excluding root)
|
|
hasUpNodes := false
|
|
for _, node := range upTree {
|
|
if node.ID != rootID {
|
|
hasUpNodes = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if hasUpNodes {
|
|
// Add a header node for dependents section
|
|
// We'll mark these with negative depth for visual distinction
|
|
for _, node := range upTree {
|
|
if node.ID == rootID {
|
|
continue // Skip root, we'll add it once from down tree
|
|
}
|
|
// Clone node and mark it as "up" direction
|
|
upNode := *node
|
|
upNode.Depth = node.Depth // Keep original depth
|
|
result = append(result, &upNode)
|
|
}
|
|
}
|
|
|
|
// Add the down tree (dependencies)
|
|
result = append(result, downTree...)
|
|
|
|
return result
|
|
}
|
|
|
|
func init() {
|
|
depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|related|parent-child|discovered-from)")
|
|
// Note: --json flag is defined as a persistent flag in main.go, not here
|
|
|
|
// Note: --json flag is defined as a persistent flag in main.go, not here
|
|
|
|
depTreeCmd.Flags().Bool("show-all-paths", false, "Show all paths to nodes (no deduplication for diamond dependencies)")
|
|
depTreeCmd.Flags().IntP("max-depth", "d", 50, "Maximum tree depth to display (safety limit)")
|
|
depTreeCmd.Flags().Bool("reverse", false, "Show dependent tree (deprecated: use --direction=up)")
|
|
depTreeCmd.Flags().String("direction", "", "Tree direction: 'down' (dependencies), 'up' (dependents), or 'both'")
|
|
depTreeCmd.Flags().String("status", "", "Filter to only show issues with this status (open, in_progress, blocked, closed)")
|
|
depTreeCmd.Flags().String("format", "", "Output format: 'mermaid' for Mermaid.js flowchart")
|
|
// Note: --json flag is defined as a persistent flag in main.go, not here
|
|
|
|
// Note: --json flag is defined as a persistent flag in main.go, not here
|
|
|
|
depCmd.AddCommand(depAddCmd)
|
|
depCmd.AddCommand(depRemoveCmd)
|
|
depCmd.AddCommand(depTreeCmd)
|
|
depCmd.AddCommand(depCyclesCmd)
|
|
rootCmd.AddCommand(depCmd)
|
|
}
|