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:
Steve Yegge
2025-10-31 15:11:29 -07:00
committed by GitHub
parent c070a9e7d4
commit aa567f6b9a
6 changed files with 321 additions and 12 deletions

View File

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

View File

@@ -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)
}
}
}

View File

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

View File

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

View File

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

View File

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