feat: add Mermaid.js format for dependency tree visualization (#191)
Adds `bd dep tree --format mermaid` to export dependency trees as Mermaid.js flowcharts. Features: - Status indicators: ☐ open, ◧ in_progress, ⚠ blocked, ☑ closed - Theme-agnostic design - Works with --reverse flag - Comprehensive unit tests following TDD Co-authored-by: David Laing <david@davidlaing.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user