Add bd tree command for context assembly
Some checks failed
CI / Check version consistency (push) Successful in 3s
CI / Check for .beads changes (push) Has been skipped
CI / Test (ubuntu-latest) (push) Failing after 8m28s
CI / Lint (push) Failing after 3m15s
CI / Test Nix Flake (push) Failing after 1m0s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (Windows - smoke) (push) Has been cancelled

New command showing full context for a bead:
- ANCESTRY: Chain from leaf to epic/goal (upward traversal)
- SIBLINGS: Parallel work under same parent
- DEPENDENCIES: What blocks/is blocked by
- DECISIONS: Key decisions extracted from comments

Output modes:
- Default: Full formatted tree view
- --compact: Single-line summary
- --pr: Copy-paste ready markdown for PR descriptions
- --json: Structured output for scripting

Implements sc-ep0zq.
This commit is contained in:
scout/crew/picard
2026-01-22 18:25:27 -08:00
committed by John Ogle
parent 2ac04c74a4
commit 9fd0ea2c67

509
cmd/bd/tree.go Normal file
View File

@@ -0,0 +1,509 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/utils"
)
// TreeContext holds the assembled context for a bead
type TreeContext struct {
Target *types.Issue `json:"target"`
Ancestry []*types.Issue `json:"ancestry"` // Root to parent chain
Siblings []*types.Issue `json:"siblings"` // Issues under same parent
Blocks []*types.IssueWithDependencyMetadata `json:"blocks"` // What this blocks
BlockedBy []*types.IssueWithDependencyMetadata `json:"blocked_by"` // What blocks this
Decisions []*Decision `json:"decisions"` // Key decisions from comments
}
// Decision represents a key decision extracted from comments
type Decision struct {
Date time.Time `json:"date"`
Summary string `json:"summary"`
Author string `json:"author"`
}
var (
treePRContext bool
treeCompact bool
)
var treeCmd = &cobra.Command{
Use: "tree <issue-id>",
GroupID: "issues",
Short: "Show context tree for an issue",
Long: `Display the full context tree for an issue, including ancestry, siblings, and dependencies.
This command helps you understand where an issue fits in the larger picture:
- ANCESTRY: The chain from this issue up to its root epic/goal
- SIBLINGS: Other issues under the same parent (parallel work)
- DEPENDENCIES: What blocks this issue and what it blocks
- DECISIONS: Key decisions from recent comments
Use --pr to output copy-paste ready markdown for PR descriptions.
Examples:
bd tree sc-abc # Show full context tree
bd tree sc-abc --pr # Output PR context markdown
bd tree sc-abc --compact # Condensed single-section view
bd tree sc-abc --json # JSON output for scripting`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
// Resolve the issue ID
var issueID string
var err error
if daemonClient != nil {
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]}
resp, resolveErr := daemonClient.ResolveID(resolveArgs)
if resolveErr != nil {
fmt.Fprintf(os.Stderr, "Error: issue '%s' not found\n", args[0])
os.Exit(1)
}
if unmarshalErr := json.Unmarshal(resp.Data, &issueID); unmarshalErr != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", unmarshalErr)
os.Exit(1)
}
} else {
issueID, err = utils.ResolvePartialID(ctx, store, args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: issue '%s' not found\n", args[0])
os.Exit(1)
}
}
// Ensure we have direct storage access for traversal
if daemonClient != nil && store == nil {
store, err = sqlite.New(ctx, dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
os.Exit(1)
}
defer func() { _ = store.Close() }()
}
if store == nil {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
os.Exit(1)
}
// Build the context tree
treeCtx, err := buildTreeContext(ctx, store, issueID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error building context: %v\n", err)
os.Exit(1)
}
// Output based on format
if jsonOutput {
outputJSON(treeCtx)
return
}
if treePRContext {
renderPRContext(treeCtx)
return
}
if treeCompact {
renderTreeCompact(treeCtx)
return
}
renderTreeFull(treeCtx)
},
}
func init() {
treeCmd.Flags().BoolVar(&treePRContext, "pr", false, "Output PR context markdown (copy-paste ready)")
treeCmd.Flags().BoolVar(&treeCompact, "compact", false, "Condensed single-section view")
treeCmd.ValidArgsFunction = issueIDCompletion
rootCmd.AddCommand(treeCmd)
}
// buildTreeContext assembles all context for an issue
func buildTreeContext(ctx context.Context, s storage.Storage, issueID string) (*TreeContext, error) {
// Get the target issue
target, err := s.GetIssue(ctx, issueID)
if err != nil {
return nil, fmt.Errorf("failed to get issue: %w", err)
}
if target == nil {
return nil, fmt.Errorf("issue %s not found", issueID)
}
treeCtx := &TreeContext{
Target: target,
}
// Build ancestry chain (traverse up via parent-child dependencies)
treeCtx.Ancestry, err = getAncestryChain(ctx, s, issueID)
if err != nil {
return nil, fmt.Errorf("failed to get ancestry: %w", err)
}
// Get siblings (other children of the same parent)
if len(treeCtx.Ancestry) > 0 {
parentID := treeCtx.Ancestry[len(treeCtx.Ancestry)-1].ID
treeCtx.Siblings, err = getSiblings(ctx, s, issueID, parentID)
if err != nil {
return nil, fmt.Errorf("failed to get siblings: %w", err)
}
}
// Get blocking dependencies
treeCtx.BlockedBy, err = s.GetDependenciesWithMetadata(ctx, issueID)
if err != nil {
return nil, fmt.Errorf("failed to get dependencies: %w", err)
}
// Filter to only blocking deps (not parent-child)
treeCtx.BlockedBy = filterBlockingDeps(treeCtx.BlockedBy)
// Get what this issue blocks
treeCtx.Blocks, err = s.GetDependentsWithMetadata(ctx, issueID)
if err != nil {
return nil, fmt.Errorf("failed to get dependents: %w", err)
}
// Filter to only blocking deps
treeCtx.Blocks = filterBlockingDeps(treeCtx.Blocks)
// Extract decisions from comments
comments, err := s.GetIssueComments(ctx, issueID)
if err == nil && len(comments) > 0 {
treeCtx.Decisions = extractDecisions(comments)
}
return treeCtx, nil
}
// getAncestryChain traverses up the parent-child hierarchy
func getAncestryChain(ctx context.Context, s storage.Storage, issueID string) ([]*types.Issue, error) {
var ancestry []*types.Issue
visited := make(map[string]bool)
currentID := issueID
for {
if visited[currentID] {
break // Cycle detected, stop
}
visited[currentID] = true
// Get dependencies with metadata to find parent-child relationship
deps, err := s.GetDependenciesWithMetadata(ctx, currentID)
if err != nil {
break
}
// Find parent (we depend on parent via parent-child type)
var parentID string
for _, dep := range deps {
if dep.DependencyType == types.DepParentChild {
parentID = dep.ID
break
}
}
if parentID == "" {
break // No parent, we're at the root
}
// Get the parent issue
parent, err := s.GetIssue(ctx, parentID)
if err != nil || parent == nil {
break
}
ancestry = append(ancestry, parent)
currentID = parentID
}
// Reverse so it's root -> leaf order
for i, j := 0, len(ancestry)-1; i < j; i, j = i+1, j-1 {
ancestry[i], ancestry[j] = ancestry[j], ancestry[i]
}
return ancestry, nil
}
// getSiblings gets other children of the same parent
func getSiblings(ctx context.Context, s storage.Storage, issueID, parentID string) ([]*types.Issue, error) {
// Get all dependents of the parent with parent-child type
deps, err := s.GetDependentsWithMetadata(ctx, parentID)
if err != nil {
return nil, err
}
var siblings []*types.Issue
for _, dep := range deps {
if dep.DependencyType == types.DepParentChild && dep.ID != issueID {
issue, err := s.GetIssue(ctx, dep.ID)
if err == nil && issue != nil {
siblings = append(siblings, issue)
}
}
}
// Sort by priority then ID
sort.Slice(siblings, func(i, j int) bool {
if siblings[i].Priority != siblings[j].Priority {
return siblings[i].Priority < siblings[j].Priority
}
return siblings[i].ID < siblings[j].ID
})
return siblings, nil
}
// filterBlockingDeps filters to only "blocks" type dependencies
func filterBlockingDeps(deps []*types.IssueWithDependencyMetadata) []*types.IssueWithDependencyMetadata {
var filtered []*types.IssueWithDependencyMetadata
for _, dep := range deps {
if dep.DependencyType == types.DepBlocks {
filtered = append(filtered, dep)
}
}
return filtered
}
// extractDecisions parses comments for decision-like content
func extractDecisions(comments []*types.Comment) []*Decision {
var decisions []*Decision
// Keywords that indicate a decision
decisionKeywords := []string{
"decided", "decision:", "chose", "choosing", "will use",
"going with", "approach:", "solution:", "resolved:",
}
for _, comment := range comments {
text := strings.ToLower(comment.Text)
for _, keyword := range decisionKeywords {
if strings.Contains(text, keyword) {
// Extract first sentence or line as summary
summary := extractSummary(comment.Text)
decisions = append(decisions, &Decision{
Date: comment.CreatedAt,
Summary: summary,
Author: comment.Author,
})
break
}
}
}
// Sort by date descending (most recent first)
sort.Slice(decisions, func(i, j int) bool {
return decisions[i].Date.After(decisions[j].Date)
})
// Limit to 5 most recent
if len(decisions) > 5 {
decisions = decisions[:5]
}
return decisions
}
// extractSummary gets first sentence or line from text
func extractSummary(text string) string {
// Try first line
lines := strings.SplitN(text, "\n", 2)
summary := strings.TrimSpace(lines[0])
// Truncate if too long
if len(summary) > 100 {
summary = summary[:97] + "..."
}
return summary
}
// renderTreeFull renders the full tree context view
func renderTreeFull(tc *TreeContext) {
fmt.Printf("\n%s Context for %s: %s\n\n",
ui.RenderAccent("📜"),
tc.Target.ID,
tc.Target.Title)
// ANCESTRY section
fmt.Printf("%s\n", ui.RenderAccent("ANCESTRY"))
if len(tc.Ancestry) == 0 {
fmt.Println(" (root issue - no parent)")
} else {
for i, ancestor := range tc.Ancestry {
indent := strings.Repeat(" ", i)
icon := getIssueTypeIcon(ancestor.IssueType)
fmt.Printf(" %s└── %s %s %s\n", indent, icon, ancestor.ID, ancestor.Title)
}
// Show target at the end
indent := strings.Repeat(" ", len(tc.Ancestry))
fmt.Printf(" %s└── 📋 %s %s ← YOU ARE HERE\n", indent, tc.Target.ID, tc.Target.Title)
}
fmt.Println()
// SIBLINGS section
fmt.Printf("%s\n", ui.RenderAccent("SIBLINGS"))
if len(tc.Siblings) == 0 {
fmt.Println(" (no siblings)")
} else {
fmt.Printf(" (same parent: %s)\n", tc.Ancestry[len(tc.Ancestry)-1].ID)
for _, sib := range tc.Siblings {
statusIcon := ui.RenderStatusIcon(string(sib.Status))
fmt.Printf(" %s %s: %s (%s)\n", statusIcon, sib.ID, sib.Title, sib.Status)
}
}
fmt.Println()
// DEPENDENCIES section
fmt.Printf("%s\n", ui.RenderAccent("DEPENDENCIES"))
if len(tc.BlockedBy) == 0 && len(tc.Blocks) == 0 {
fmt.Println(" (no blocking dependencies)")
} else {
if len(tc.BlockedBy) > 0 {
fmt.Println(" ← Blocked by:")
for _, dep := range tc.BlockedBy {
statusIcon := ui.RenderStatusIcon(string(dep.Status))
fmt.Printf(" %s %s: %s (%s)\n", statusIcon, dep.ID, dep.Title, dep.Status)
}
}
if len(tc.Blocks) > 0 {
fmt.Println(" → Blocks:")
for _, dep := range tc.Blocks {
statusIcon := ui.RenderStatusIcon(string(dep.Status))
fmt.Printf(" %s %s: %s (%s)\n", statusIcon, dep.ID, dep.Title, dep.Status)
}
}
}
fmt.Println()
// DECISIONS section
if len(tc.Decisions) > 0 {
fmt.Printf("%s\n", ui.RenderAccent("RECENT DECISIONS"))
for _, d := range tc.Decisions {
dateStr := d.Date.Format("2006-01-02")
fmt.Printf(" - %s: %s\n", dateStr, d.Summary)
}
fmt.Println()
}
}
// renderTreeCompact renders a condensed view
func renderTreeCompact(tc *TreeContext) {
fmt.Printf("\n%s %s: %s\n", ui.RenderAccent("📜"), tc.Target.ID, tc.Target.Title)
// Show ancestry as breadcrumb
if len(tc.Ancestry) > 0 {
var crumbs []string
for _, a := range tc.Ancestry {
crumbs = append(crumbs, a.ID)
}
crumbs = append(crumbs, tc.Target.ID)
fmt.Printf(" Path: %s\n", strings.Join(crumbs, " → "))
}
// Show counts
fmt.Printf(" Siblings: %d | Blocked by: %d | Blocks: %d\n",
len(tc.Siblings), len(tc.BlockedBy), len(tc.Blocks))
fmt.Println()
}
// renderPRContext outputs markdown for PR descriptions
func renderPRContext(tc *TreeContext) {
fmt.Println("## Context")
fmt.Println()
// Show ancestry
if len(tc.Ancestry) > 0 {
root := tc.Ancestry[0]
fmt.Printf("Part of [%s](%s) (%s).\n", root.Title, "bd:"+root.ID, root.IssueType)
if len(tc.Ancestry) > 1 {
parent := tc.Ancestry[len(tc.Ancestry)-1]
fmt.Printf("Direct parent: [%s](%s).\n", parent.Title, "bd:"+parent.ID)
}
fmt.Println()
}
// Show what this addresses
fmt.Printf("This PR addresses [%s](%s).\n", tc.Target.Title, "bd:"+tc.Target.ID)
fmt.Println()
// Related work (siblings)
if len(tc.Siblings) > 0 {
fmt.Println("### Related Work")
fmt.Println()
for _, sib := range tc.Siblings {
statusMark := "[ ]"
if sib.Status == types.StatusClosed {
statusMark = "[x]"
} else if sib.Status == types.StatusInProgress {
statusMark = "[~]"
}
fmt.Printf("- %s %s: %s\n", statusMark, sib.ID, sib.Title)
}
fmt.Println()
}
// Dependencies
if len(tc.BlockedBy) > 0 || len(tc.Blocks) > 0 {
fmt.Println("### Dependencies")
fmt.Println()
if len(tc.BlockedBy) > 0 {
fmt.Println("Requires:")
for _, dep := range tc.BlockedBy {
status := "pending"
if dep.Status == types.StatusClosed {
status = "done"
}
fmt.Printf("- %s (%s)\n", dep.ID, status)
}
}
if len(tc.Blocks) > 0 {
fmt.Println("Enables:")
for _, dep := range tc.Blocks {
fmt.Printf("- %s\n", dep.ID)
}
}
fmt.Println()
}
// Key decisions
if len(tc.Decisions) > 0 {
fmt.Println("### Key Decisions")
fmt.Println()
for _, d := range tc.Decisions {
fmt.Printf("- %s\n", d.Summary)
}
fmt.Println()
}
}
// getIssueTypeIcon returns an icon for issue type
func getIssueTypeIcon(issueType types.IssueType) string {
switch issueType {
case types.TypeEpic:
return "🎯"
case types.TypeFeature:
return "✨"
case types.TypeBug:
return "🐛"
case types.TypeChore:
return "🔧"
default:
return "📋"
}
}