diff --git a/cmd/bd/dep.go b/cmd/bd/dep.go index a9e44ee9..e153ea99 100644 --- a/cmd/bd/dep.go +++ b/cmd/bd/dep.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "os" + "strings" "github.com/fatih/color" "github.com/spf13/cobra" @@ -25,7 +26,6 @@ var depAddCmd = &cobra.Command{ Args: cobra.ExactArgs(2), Run: func(cmd *cobra.Command, args []string) { depType, _ := cmd.Flags().GetString("type") - jsonOutput, _ := cmd.Flags().GetBool("json") ctx := context.Background() @@ -148,7 +148,6 @@ var depRemoveCmd = &cobra.Command{ Short: "Remove a dependency", Args: cobra.ExactArgs(2), Run: func(cmd *cobra.Command, args []string) { - jsonOutput, _ := cmd.Flags().GetBool("json") ctx := context.Background() // Resolve partial IDs first @@ -240,7 +239,6 @@ var depTreeCmd = &cobra.Command{ Short: "Show dependency tree", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - jsonOutput, _ := cmd.Flags().GetBool("json") ctx := context.Background() // Resolve partial ID first @@ -276,6 +274,7 @@ var depTreeCmd = &cobra.Command{ showAllPaths, _ := cmd.Flags().GetBool("show-all-paths") maxDepth, _ := cmd.Flags().GetInt("max-depth") reverse, _ := cmd.Flags().GetBool("reverse") + formatStr, _ := cmd.Flags().GetString("format") if maxDepth < 1 { fmt.Fprintf(os.Stderr, "Error: --max-depth must be >= 1\n") @@ -288,6 +287,12 @@ var depTreeCmd = &cobra.Command{ os.Exit(1) } + // Handle mermaid format + if formatStr == "mermaid" { + outputMermaidTree(tree, args[0]) + return + } + if jsonOutput { // Always output array, even if empty if tree == nil { @@ -341,8 +346,6 @@ var depCyclesCmd = &cobra.Command{ Use: "cycles", Short: "Detect dependency cycles", Run: func(cmd *cobra.Command, args []string) { - jsonOutput, _ := cmd.Flags().GetBool("json") - // If daemon is running but doesn't support this command, use direct storage if daemonClient != nil && store == nil { var err error @@ -388,19 +391,71 @@ var depCyclesCmd = &cobra.Command{ }, } +// 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 "?" + } +} + func init() { depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|related|parent-child|discovered-from)") depAddCmd.Flags().Bool("json", false, "Output JSON format") - + depRemoveCmd.Flags().Bool("json", false, "Output JSON format") - + 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 (what was discovered from this) instead of dependency tree (what blocks this)") + depTreeCmd.Flags().String("format", "", "Output format: 'mermaid' for Mermaid.js flowchart") depTreeCmd.Flags().Bool("json", false, "Output JSON format") - + depCyclesCmd.Flags().Bool("json", false, "Output JSON format") - + depCmd.AddCommand(depAddCmd) depCmd.AddCommand(depRemoveCmd) depCmd.AddCommand(depTreeCmd) diff --git a/cmd/bd/dep_test.go b/cmd/bd/dep_test.go index 85c73619..f19fc2f5 100644 --- a/cmd/bd/dep_test.go +++ b/cmd/bd/dep_test.go @@ -1,9 +1,13 @@ package main import ( + "bytes" "context" "fmt" + "io" + "os" "path/filepath" + "strings" "testing" "time" @@ -266,3 +270,217 @@ func TestDepRemove(t *testing.T) { t.Errorf("Expected 0 dependencies after removal, got %d", len(deps)) } } + +func TestDepTreeFormatFlag(t *testing.T) { + // Test that the --format flag exists on depTreeCmd + flag := depTreeCmd.Flags().Lookup("format") + if flag == nil { + t.Fatal("depTreeCmd should have --format flag") + } + + // Test default value is empty string + if flag.DefValue != "" { + t.Errorf("Expected default format='', got %q", flag.DefValue) + } + + // Test usage text mentions mermaid + if !strings.Contains(flag.Usage, "mermaid") { + t.Errorf("Expected flag usage to mention 'mermaid', got %q", flag.Usage) + } +} + +func TestGetStatusEmoji(t *testing.T) { + tests := []struct { + status types.Status + want string + }{ + {types.StatusOpen, "☐"}, + {types.StatusInProgress, "◧"}, + {types.StatusBlocked, "⚠"}, + {types.StatusClosed, "☑"}, + {types.Status("unknown"), "?"}, + } + + for _, tt := range tests { + t.Run(string(tt.status), func(t *testing.T) { + got := getStatusEmoji(tt.status) + if got != tt.want { + t.Errorf("getStatusEmoji(%q) = %q, want %q", tt.status, got, tt.want) + } + }) + } +} + +func TestOutputMermaidTree(t *testing.T) { + tests := []struct { + name string + tree []*types.TreeNode + rootID string + want []string // Lines that must appear in output + }{ + { + name: "empty tree", + tree: []*types.TreeNode{}, + rootID: "test-1", + want: []string{ + "flowchart TD", + `test-1["No dependencies"]`, + }, + }, + { + name: "single dependency", + tree: []*types.TreeNode{ + { + Issue: types.Issue{ID: "test-1", Title: "Task 1", Status: types.StatusInProgress}, + Depth: 0, + ParentID: "", + }, + { + Issue: types.Issue{ID: "test-2", Title: "Task 2", Status: types.StatusClosed}, + Depth: 1, + ParentID: "test-1", + }, + }, + rootID: "test-1", + want: []string{ + "flowchart TD", + `test-1["◧ test-1: Task 1"]`, + `test-2["☑ test-2: Task 2"]`, + "test-1 --> test-2", + }, + }, + { + name: "multiple dependencies", + tree: []*types.TreeNode{ + { + Issue: types.Issue{ID: "test-1", Title: "Main", Status: types.StatusOpen}, + Depth: 0, + ParentID: "", + }, + { + Issue: types.Issue{ID: "test-2", Title: "Sub 1", Status: types.StatusClosed}, + Depth: 1, + ParentID: "test-1", + }, + { + Issue: types.Issue{ID: "test-3", Title: "Sub 2", Status: types.StatusBlocked}, + Depth: 1, + ParentID: "test-1", + }, + }, + rootID: "test-1", + want: []string{ + "flowchart TD", + `test-1["☐ test-1: Main"]`, + `test-2["☑ test-2: Sub 1"]`, + `test-3["⚠ test-3: Sub 2"]`, + "test-1 --> test-2", + "test-1 --> test-3", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Capture stdout + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + outputMermaidTree(tt.tree, tt.rootID) + + w.Close() + os.Stdout = old + + var buf bytes.Buffer + io.Copy(&buf, r) + output := buf.String() + + // Verify all expected lines appear + for _, line := range tt.want { + if !strings.Contains(output, line) { + t.Errorf("expected output to contain %q, got:\n%s", line, output) + } + } + }) + } +} + +func TestOutputMermaidTree_Siblings(t *testing.T) { + // Test case: Siblings with children (reproduces issue with wrong parent inference) + // Structure: + // BD-1 (root) + // ├── BD-2 (sibling 1) + // │ └── BD-4 (child of BD-2) + // └── BD-3 (sibling 2) + // └── BD-5 (child of BD-3) + tree := []*types.TreeNode{ + { + Issue: types.Issue{ID: "BD-1", Title: "Parent", Status: types.StatusOpen}, + Depth: 0, + ParentID: "", + }, + { + Issue: types.Issue{ID: "BD-2", Title: "Sibling 1", Status: types.StatusOpen}, + Depth: 1, + ParentID: "BD-1", + }, + { + Issue: types.Issue{ID: "BD-3", Title: "Sibling 2", Status: types.StatusOpen}, + Depth: 1, + ParentID: "BD-1", + }, + { + Issue: types.Issue{ID: "BD-4", Title: "Child of Sibling 1", Status: types.StatusOpen}, + Depth: 2, + ParentID: "BD-2", + }, + { + Issue: types.Issue{ID: "BD-5", Title: "Child of Sibling 2", Status: types.StatusOpen}, + Depth: 2, + ParentID: "BD-3", + }, + } + + // Capture stdout + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + outputMermaidTree(tree, "BD-1") + + w.Close() + os.Stdout = old + + var buf bytes.Buffer + io.Copy(&buf, r) + output := buf.String() + + // Verify correct edges exist + correctEdges := []string{ + "BD-1 --> BD-2", + "BD-1 --> BD-3", + "BD-2 --> BD-4", + "BD-3 --> BD-5", + } + + for _, edge := range correctEdges { + if !strings.Contains(output, edge) { + t.Errorf("expected edge %q to be present, got:\n%s", edge, output) + } + } + + // Verify incorrect edges do NOT exist (siblings shouldn't be connected) + incorrectEdges := []string{ + "BD-2 --> BD-3", // Siblings shouldn't be connected + "BD-3 --> BD-4", // BD-4's parent is BD-2, not BD-3 + "BD-4 --> BD-3", // Wrong direction + "BD-4 --> BD-5", // These are cousins, not parent-child + } + + for _, edge := range incorrectEdges { + if strings.Contains(output, edge) { + t.Errorf("incorrect edge %q should NOT be present, got:\n%s", edge, output) + } + } +} diff --git a/commands/dep.md b/commands/dep.md index ad1afa07..b4a6a94d 100644 --- a/commands/dep.md +++ b/commands/dep.md @@ -23,6 +23,7 @@ Manage dependencies between beads issues. - $2: Issue ID - Flags: - `--reverse`: Show dependent tree (what was discovered from this) instead of dependency tree (what blocks this) + - `--format mermaid`: Output as Mermaid.js flowchart (renders in GitHub/GitLab markdown) - `--json`: Output as JSON - `--max-depth N`: Limit tree depth (default: 50) - `--show-all-paths`: Show all paths (no deduplication for diamond dependencies) @@ -36,12 +37,45 @@ Manage dependencies between beads issues. - **parent-child**: Epic/subtask relationship - **discovered-from**: Track issues found during work +## Mermaid Format + +The `--format mermaid` option outputs the dependency tree as a Mermaid.js flowchart: + +**Example:** +```bash +bd dep tree bd-1 --format mermaid +``` + +Output can be embedded in markdown: + +````markdown +```mermaid +flowchart TD + bd-1["◧ bd-1: Main task"] + bd-2["☑ bd-2: Subtask"] + + bd-1 --> bd-2 +``` +```` + +**Status Indicators:** + +Each node includes a symbol indicator for quick visual status identification: + +- ☐ **Open** - Not started yet (empty checkbox) +- ◧ **In Progress** - Currently being worked on (half-filled box) +- ⚠ **Blocked** - Waiting on something (warning sign) +- ☑ **Closed** - Completed! (checked checkbox) + +The diagram colors are determined by your Mermaid theme (default, dark, forest, neutral, or base). Mermaid diagrams render natively in GitHub, GitLab, VSCode markdown preview, and can be imported to Miro. + ## Examples - `bd dep add bd-10 bd-20 --type blocks`: bd-10 blocks bd-20 - `bd dep tree bd-20`: Show what blocks bd-20 (dependency tree going UP) - `bd dep tree bd-1 --reverse`: Show what was discovered from bd-1 (dependent tree going DOWN) - `bd dep tree bd-1 --reverse --max-depth 3`: Show discovery tree with depth limit +- `bd dep tree bd-20 --format mermaid > tree.md`: Generate Mermaid diagram for documentation - `bd dep cycles`: Check for circular dependencies ## Reverse Mode: Discovery Trees diff --git a/internal/storage/memory/memory.go b/internal/storage/memory/memory.go index 45fffb19..eec6abdc 100644 --- a/internal/storage/memory/memory.go +++ b/internal/storage/memory/memory.go @@ -616,6 +616,7 @@ func (m *MemoryStorage) SetJSONLFileHash(ctx context.Context, fileHash string) e // GetDependencyTree gets the dependency tree for an issue func (m *MemoryStorage) GetDependencyTree(ctx context.Context, issueID string, maxDepth int, showAllPaths bool, reverse bool) ([]*types.TreeNode, error) { // Simplified implementation - just return direct dependencies + // Note: reverse parameter is accepted for interface compatibility but not fully implemented in memory storage deps, err := m.GetDependencies(ctx, issueID) if err != nil { return nil, err diff --git a/internal/storage/sqlite/dependencies.go b/internal/storage/sqlite/dependencies.go index 3ae79540..53cfdd49 100644 --- a/internal/storage/sqlite/dependencies.go +++ b/internal/storage/sqlite/dependencies.go @@ -587,7 +587,7 @@ func (s *SQLiteStorage) GetDependencyTree(ctx context.Context, issueID string, m if err != nil { return nil, fmt.Errorf("failed to scan tree node: %w", err) } - _ = parentID // Silence unused variable warning + node.ParentID = parentID if closedAt.Valid { node.ClosedAt = &closedAt.Time diff --git a/internal/types/types.go b/internal/types/types.go index dc724c39..5f2764b4 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -222,8 +222,9 @@ type BlockedIssue struct { // TreeNode represents a node in a dependency tree type TreeNode struct { Issue - Depth int `json:"depth"` - Truncated bool `json:"truncated"` + Depth int `json:"depth"` + ParentID string `json:"parent_id"` + Truncated bool `json:"truncated"` } // Statistics provides aggregate metrics