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
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:
509
cmd/bd/tree.go
Normal file
509
cmd/bd/tree.go
Normal 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 "📋"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user