From 9fd0ea2c676ffd5495b6cfc01e287f3f44ace317 Mon Sep 17 00:00:00 2001 From: scout/crew/picard Date: Thu, 22 Jan 2026 18:25:27 -0800 Subject: [PATCH] Add bd tree command for context assembly 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. --- cmd/bd/tree.go | 509 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 509 insertions(+) create mode 100644 cmd/bd/tree.go diff --git a/cmd/bd/tree.go b/cmd/bd/tree.go new file mode 100644 index 00000000..c4b039d7 --- /dev/null +++ b/cmd/bd/tree.go @@ -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 ", + 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 "šŸ“‹" + } +}