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 "šŸ“‹" } }