Files
beads/cmd/bd/show.go
Julian Knutsen a2bf15ee48 feat(bd): add --children flag to bd show command (#1069)
Adds a --children flag to the bd show command that displays only the
children of the specified issue. This is useful for quickly viewing
child steps of an epic without the full issue details.

The flag supports:
- Default mode: shows children with full dependency line formatting
- --short mode: shows compact one-liner per child
- --json mode: outputs children in JSON format

Fixes gt-lzf3.5

Co-authored-by: toast <julianknutsen@users.noreply.github>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 11:41:28 -08:00

1045 lines
33 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"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"
)
var showCmd = &cobra.Command{
Use: "show [id...]",
GroupID: "issues",
Short: "Show issue details",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
showThread, _ := cmd.Flags().GetBool("thread")
shortMode, _ := cmd.Flags().GetBool("short")
showRefs, _ := cmd.Flags().GetBool("refs")
showChildren, _ := cmd.Flags().GetBool("children")
ctx := rootCtx
// Check database freshness before reading
// Skip check when using daemon (daemon auto-imports on staleness)
if daemonClient == nil {
if err := ensureDatabaseFresh(ctx); err != nil {
FatalErrorRespectJSON("%v", err)
}
}
// Resolve partial IDs first (daemon mode only - direct mode uses routed resolution)
var resolvedIDs []string
var routedArgs []string // IDs that need cross-repo routing (bypass daemon)
if daemonClient != nil {
// In daemon mode, resolve via RPC - but check routing first
for _, id := range args {
// Check if this ID needs routing to a different beads directory
if needsRouting(id) {
routedArgs = append(routedArgs, id)
continue
}
resolveArgs := &rpc.ResolveIDArgs{ID: id}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
FatalErrorRespectJSON("resolving ID %s: %v", id, err)
}
var resolvedID string
if err := json.Unmarshal(resp.Data, &resolvedID); err != nil {
FatalErrorRespectJSON("unmarshaling resolved ID: %v", err)
}
resolvedIDs = append(resolvedIDs, resolvedID)
}
}
// Note: Direct mode uses resolveAndGetIssueWithRouting for prefix-based routing
// Handle --thread flag: show full conversation thread
if showThread {
if daemonClient != nil && len(resolvedIDs) > 0 {
showMessageThread(ctx, resolvedIDs[0], jsonOutput)
return
} else if len(args) > 0 {
// Direct mode - resolve first arg with routing
result, err := resolveAndGetIssueWithRouting(ctx, store, args[0])
if result != nil {
defer result.Close()
}
if err == nil && result != nil && result.ResolvedID != "" {
showMessageThread(ctx, result.ResolvedID, jsonOutput)
return
}
}
}
// Handle --refs flag: show issues that reference this issue
if showRefs {
showIssueRefs(ctx, args, resolvedIDs, routedArgs, jsonOutput)
return
}
// Handle --children flag: show only children of this issue
if showChildren {
showIssueChildren(ctx, args, resolvedIDs, routedArgs, jsonOutput, shortMode)
return
}
// If daemon is running, use RPC (but fall back to direct mode for routed IDs)
if daemonClient != nil {
allDetails := []interface{}{}
displayIdx := 0
// First, handle routed IDs via direct mode
for _, id := range routedArgs {
result, err := resolveAndGetIssueWithRouting(ctx, store, id)
if err != nil {
if result != nil {
result.Close()
}
fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", id, err)
continue
}
if result == nil || result.Issue == nil {
if result != nil {
result.Close()
}
fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
continue
}
issue := result.Issue
issueStore := result.Store
if shortMode {
fmt.Println(formatShortIssue(issue))
result.Close()
continue
}
if jsonOutput {
// Get labels and deps for JSON output
details := &types.IssueDetails{Issue: *issue}
details.Labels, _ = issueStore.GetLabels(ctx, issue.ID)
if sqliteStore, ok := issueStore.(*sqlite.SQLiteStorage); ok {
details.Dependencies, _ = sqliteStore.GetDependenciesWithMetadata(ctx, issue.ID)
details.Dependents, _ = sqliteStore.GetDependentsWithMetadata(ctx, issue.ID)
}
details.Comments, _ = issueStore.GetIssueComments(ctx, issue.ID)
// Compute parent from dependencies
for _, dep := range details.Dependencies {
if dep.DependencyType == types.DepParentChild {
details.Parent = &dep.ID
break
}
}
allDetails = append(allDetails, details)
} else {
if displayIdx > 0 {
fmt.Println("\n" + ui.RenderMuted(strings.Repeat("─", 60)))
}
// Tufte-aligned header: STATUS_ICON ID · Title [Priority · STATUS]
fmt.Printf("\n%s\n", formatIssueHeader(issue))
// Metadata: Owner · Type | Created · Updated
fmt.Println(formatIssueMetadata(issue))
if issue.Description != "" {
fmt.Printf("\n%s\n%s\n", ui.RenderBold("DESCRIPTION"), ui.RenderMarkdown(issue.Description))
}
fmt.Println()
displayIdx++
}
result.Close() // Close immediately after processing each routed ID
}
// Then, handle local IDs via daemon
for _, id := range resolvedIDs {
showArgs := &rpc.ShowArgs{ID: id}
resp, err := daemonClient.Show(showArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", id, err)
continue
}
if jsonOutput {
var details types.IssueDetails
if err := json.Unmarshal(resp.Data, &details); err == nil {
// Compute parent from dependencies
for _, dep := range details.Dependencies {
if dep.DependencyType == types.DepParentChild {
details.Parent = &dep.ID
break
}
}
allDetails = append(allDetails, details)
}
} else {
// Check if issue exists (daemon returns null for non-existent issues)
if string(resp.Data) == "null" || len(resp.Data) == 0 {
fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
continue
}
// Parse response first to check shortMode before output
var details types.IssueDetails
if err := json.Unmarshal(resp.Data, &details); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1)
}
issue := &details.Issue
if shortMode {
fmt.Println(formatShortIssue(issue))
continue
}
if displayIdx > 0 {
fmt.Println("\n" + ui.RenderMuted(strings.Repeat("─", 60)))
}
displayIdx++
// Tufte-aligned header: STATUS_ICON ID · Title [Priority · STATUS]
fmt.Printf("\n%s\n", formatIssueHeader(issue))
// Metadata: Owner · Type | Created · Updated
fmt.Println(formatIssueMetadata(issue))
// Compaction info (if applicable)
if issue.CompactionLevel > 0 {
fmt.Println()
if issue.OriginalSize > 0 {
currentSize := len(issue.Description) + len(issue.Design) + len(issue.Notes) + len(issue.AcceptanceCriteria)
saved := issue.OriginalSize - currentSize
if saved > 0 {
reduction := float64(saved) / float64(issue.OriginalSize) * 100
fmt.Printf("📊 %d → %d bytes (%.0f%% reduction)\n",
issue.OriginalSize, currentSize, reduction)
}
}
}
// Content sections
if issue.Description != "" {
fmt.Printf("\n%s\n%s\n", ui.RenderBold("DESCRIPTION"), ui.RenderMarkdown(issue.Description))
}
if issue.Design != "" {
fmt.Printf("\n%s\n%s\n", ui.RenderBold("DESIGN"), ui.RenderMarkdown(issue.Design))
}
if issue.Notes != "" {
fmt.Printf("\n%s\n%s\n", ui.RenderBold("NOTES"), ui.RenderMarkdown(issue.Notes))
}
if issue.AcceptanceCriteria != "" {
fmt.Printf("\n%s\n%s\n", ui.RenderBold("ACCEPTANCE CRITERIA"), ui.RenderMarkdown(issue.AcceptanceCriteria))
}
if len(details.Labels) > 0 {
fmt.Printf("\n%s %s\n", ui.RenderBold("LABELS:"), strings.Join(details.Labels, ", "))
}
// Dependencies with semantic colors
if len(details.Dependencies) > 0 {
fmt.Printf("\n%s\n", ui.RenderBold("DEPENDS ON"))
for _, dep := range details.Dependencies {
fmt.Println(formatDependencyLine("→", dep))
}
}
// Dependents grouped by type with semantic colors
if len(details.Dependents) > 0 {
var blocks, children, related, discovered []*types.IssueWithDependencyMetadata
for _, dep := range details.Dependents {
switch dep.DependencyType {
case types.DepBlocks:
blocks = append(blocks, dep)
case types.DepParentChild:
children = append(children, dep)
case types.DepRelated:
related = append(related, dep)
case types.DepDiscoveredFrom:
discovered = append(discovered, dep)
default:
blocks = append(blocks, dep)
}
}
if len(children) > 0 {
fmt.Printf("\n%s\n", ui.RenderBold("CHILDREN"))
for _, dep := range children {
fmt.Println(formatDependencyLine("↳", dep))
}
}
if len(blocks) > 0 {
fmt.Printf("\n%s\n", ui.RenderBold("BLOCKS"))
for _, dep := range blocks {
fmt.Println(formatDependencyLine("←", dep))
}
}
if len(related) > 0 {
fmt.Printf("\n%s\n", ui.RenderBold("RELATED"))
for _, dep := range related {
fmt.Println(formatDependencyLine("↔", dep))
}
}
if len(discovered) > 0 {
fmt.Printf("\n%s\n", ui.RenderBold("DISCOVERED"))
for _, dep := range discovered {
fmt.Println(formatDependencyLine("◊", dep))
}
}
}
if len(details.Comments) > 0 {
fmt.Printf("\n%s\n", ui.RenderBold("COMMENTS"))
for _, comment := range details.Comments {
fmt.Printf(" %s %s\n", ui.RenderMuted(comment.CreatedAt.Format("2006-01-02")), comment.Author)
rendered := ui.RenderMarkdown(comment.Text)
// TrimRight removes trailing newlines that Glamour adds, preventing extra blank lines
for _, line := range strings.Split(strings.TrimRight(rendered, "\n"), "\n") {
fmt.Printf(" %s\n", line)
}
}
}
fmt.Println()
}
}
if jsonOutput && len(allDetails) > 0 {
outputJSON(allDetails)
}
// Track first shown issue as last touched
if len(resolvedIDs) > 0 {
SetLastTouchedID(resolvedIDs[0])
} else if len(routedArgs) > 0 {
SetLastTouchedID(routedArgs[0])
}
return
}
// Direct mode - use routed resolution for cross-repo lookups
allDetails := []interface{}{}
for idx, id := range args {
// Resolve and get issue with routing (e.g., gt-xyz routes to gastown)
result, err := resolveAndGetIssueWithRouting(ctx, store, id)
if err != nil {
if result != nil {
result.Close()
}
fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", id, err)
continue
}
if result == nil || result.Issue == nil {
if result != nil {
result.Close()
}
fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
continue
}
issue := result.Issue
issueStore := result.Store // Use the store that contains this issue
// Note: result.Close() called at end of loop iteration
if shortMode {
fmt.Println(formatShortIssue(issue))
result.Close()
continue
}
if jsonOutput {
// Include labels, dependencies (with metadata), dependents (with metadata), and comments in JSON output
details := &types.IssueDetails{Issue: *issue}
details.Labels, _ = issueStore.GetLabels(ctx, issue.ID)
// Get dependencies with metadata (dependency_type field)
if sqliteStore, ok := issueStore.(*sqlite.SQLiteStorage); ok {
details.Dependencies, _ = sqliteStore.GetDependenciesWithMetadata(ctx, issue.ID)
details.Dependents, _ = sqliteStore.GetDependentsWithMetadata(ctx, issue.ID)
} else {
// Fallback to regular methods without metadata for other storage backends
deps, _ := issueStore.GetDependencies(ctx, issue.ID)
for _, dep := range deps {
details.Dependencies = append(details.Dependencies, &types.IssueWithDependencyMetadata{Issue: *dep})
}
dependents, _ := issueStore.GetDependents(ctx, issue.ID)
for _, dependent := range dependents {
details.Dependents = append(details.Dependents, &types.IssueWithDependencyMetadata{Issue: *dependent})
}
}
details.Comments, _ = issueStore.GetIssueComments(ctx, issue.ID)
// Compute parent from dependencies
for _, dep := range details.Dependencies {
if dep.DependencyType == types.DepParentChild {
details.Parent = &dep.ID
break
}
}
allDetails = append(allDetails, details)
result.Close() // Close before continuing to next iteration
continue
}
if idx > 0 {
fmt.Println("\n" + ui.RenderMuted(strings.Repeat("─", 60)))
}
// Tufte-aligned header: STATUS_ICON ID · Title [Priority · STATUS]
fmt.Printf("\n%s\n", formatIssueHeader(issue))
// Metadata: Owner · Type | Created · Updated
fmt.Println(formatIssueMetadata(issue))
// Compaction info (if applicable)
if issue.CompactionLevel > 0 {
fmt.Println()
if issue.OriginalSize > 0 {
currentSize := len(issue.Description) + len(issue.Design) + len(issue.Notes) + len(issue.AcceptanceCriteria)
saved := issue.OriginalSize - currentSize
if saved > 0 {
reduction := float64(saved) / float64(issue.OriginalSize) * 100
fmt.Printf("📊 %d → %d bytes (%.0f%% reduction)\n",
issue.OriginalSize, currentSize, reduction)
}
}
}
// Content sections
if issue.Description != "" {
fmt.Printf("\n%s\n%s\n", ui.RenderBold("DESCRIPTION"), ui.RenderMarkdown(issue.Description))
}
if issue.Design != "" {
fmt.Printf("\n%s\n%s\n", ui.RenderBold("DESIGN"), ui.RenderMarkdown(issue.Design))
}
if issue.Notes != "" {
fmt.Printf("\n%s\n%s\n", ui.RenderBold("NOTES"), ui.RenderMarkdown(issue.Notes))
}
if issue.AcceptanceCriteria != "" {
fmt.Printf("\n%s\n%s\n", ui.RenderBold("ACCEPTANCE CRITERIA"), ui.RenderMarkdown(issue.AcceptanceCriteria))
}
// Show labels
labels, _ := issueStore.GetLabels(ctx, issue.ID)
if len(labels) > 0 {
fmt.Printf("\n%s %s\n", ui.RenderBold("LABELS:"), strings.Join(labels, ", "))
}
// Show dependencies with semantic colors
deps, _ := issueStore.GetDependencies(ctx, issue.ID)
if len(deps) > 0 {
fmt.Printf("\n%s\n", ui.RenderBold("DEPENDS ON"))
for _, dep := range deps {
fmt.Println(formatSimpleDependencyLine("→", dep))
}
}
// Show dependents - grouped by dependency type for clarity
// Use GetDependentsWithMetadata to get the dependency type
sqliteStore, ok := issueStore.(*sqlite.SQLiteStorage)
if ok {
dependentsWithMeta, _ := sqliteStore.GetDependentsWithMetadata(ctx, issue.ID)
if len(dependentsWithMeta) > 0 {
// Group by dependency type
var blocks, children, related, discovered []*types.IssueWithDependencyMetadata
for _, dep := range dependentsWithMeta {
switch dep.DependencyType {
case types.DepBlocks:
blocks = append(blocks, dep)
case types.DepParentChild:
children = append(children, dep)
case types.DepRelated:
related = append(related, dep)
case types.DepDiscoveredFrom:
discovered = append(discovered, dep)
default:
blocks = append(blocks, dep) // Default to blocks
}
}
if len(children) > 0 {
fmt.Printf("\n%s\n", ui.RenderBold("CHILDREN"))
for _, dep := range children {
fmt.Println(formatDependencyLine("↳", dep))
}
}
if len(blocks) > 0 {
fmt.Printf("\n%s\n", ui.RenderBold("BLOCKS"))
for _, dep := range blocks {
fmt.Println(formatDependencyLine("←", dep))
}
}
if len(related) > 0 {
fmt.Printf("\n%s\n", ui.RenderBold("RELATED"))
for _, dep := range related {
fmt.Println(formatDependencyLine("↔", dep))
}
}
if len(discovered) > 0 {
fmt.Printf("\n%s\n", ui.RenderBold("DISCOVERED"))
for _, dep := range discovered {
fmt.Println(formatDependencyLine("◊", dep))
}
}
}
} else {
// Fallback for non-SQLite storage
dependents, _ := issueStore.GetDependents(ctx, issue.ID)
if len(dependents) > 0 {
fmt.Printf("\n%s\n", ui.RenderBold("BLOCKS"))
for _, dep := range dependents {
fmt.Println(formatSimpleDependencyLine("←", dep))
}
}
}
// Show comments
comments, _ := issueStore.GetIssueComments(ctx, issue.ID)
if len(comments) > 0 {
fmt.Printf("\n%s\n", ui.RenderBold("COMMENTS"))
for _, comment := range comments {
fmt.Printf(" %s %s\n", ui.RenderMuted(comment.CreatedAt.Format("2006-01-02")), comment.Author)
rendered := ui.RenderMarkdown(comment.Text)
// TrimRight removes trailing newlines that Glamour adds, preventing extra blank lines
for _, line := range strings.Split(strings.TrimRight(rendered, "\n"), "\n") {
fmt.Printf(" %s\n", line)
}
}
}
fmt.Println()
result.Close() // Close routed storage after each iteration
}
if jsonOutput && len(allDetails) > 0 {
outputJSON(allDetails)
} else if len(allDetails) > 0 {
// Show tip after successful show (non-JSON mode)
maybeShowTip(store)
}
// Track first shown issue as last touched
if len(args) > 0 {
SetLastTouchedID(args[0])
}
},
}
// formatShortIssue returns a compact one-line representation of an issue
// Format: STATUS_ICON ID PRIORITY [Type] Title
func formatShortIssue(issue *types.Issue) string {
statusIcon := ui.RenderStatusIcon(string(issue.Status))
priorityTag := ui.RenderPriority(issue.Priority)
// Type badge only for notable types
typeBadge := ""
switch issue.IssueType {
case "epic":
typeBadge = ui.TypeEpicStyle.Render("[epic]") + " "
case "bug":
typeBadge = ui.TypeBugStyle.Render("[bug]") + " "
}
// Closed issues: entire line is muted
if issue.Status == types.StatusClosed {
return fmt.Sprintf("%s %s %s %s%s",
statusIcon,
ui.RenderMuted(issue.ID),
ui.RenderMuted(fmt.Sprintf("● P%d", issue.Priority)),
ui.RenderMuted(string(issue.IssueType)),
ui.RenderMuted(" "+issue.Title))
}
return fmt.Sprintf("%s %s %s %s%s", statusIcon, issue.ID, priorityTag, typeBadge, issue.Title)
}
// formatIssueHeader returns the Tufte-aligned header line
// Format: ID · Title [Priority · STATUS]
// All elements in bd show get semantic colors since focus is on one issue
func formatIssueHeader(issue *types.Issue) string {
// Get status icon and style
statusIcon := ui.RenderStatusIcon(string(issue.Status))
statusStyle := ui.GetStatusStyle(string(issue.Status))
statusStr := statusStyle.Render(strings.ToUpper(string(issue.Status)))
// Priority with semantic color (includes ● icon)
priorityTag := ui.RenderPriority(issue.Priority)
// Type badge for notable types
typeBadge := ""
switch issue.IssueType {
case "epic":
typeBadge = " " + ui.TypeEpicStyle.Render("[EPIC]")
case "bug":
typeBadge = " " + ui.TypeBugStyle.Render("[BUG]")
}
// Compaction indicator
tierEmoji := ""
switch issue.CompactionLevel {
case 1:
tierEmoji = " 🗜️"
case 2:
tierEmoji = " 📦"
}
// Build header: STATUS_ICON ID · Title [Priority · STATUS]
idStyled := ui.RenderAccent(issue.ID)
return fmt.Sprintf("%s %s%s · %s%s [%s · %s]",
statusIcon, idStyled, typeBadge, issue.Title, tierEmoji, priorityTag, statusStr)
}
// formatIssueMetadata returns the metadata line(s) with grouped info
// Format: Owner: user · Type: task
//
// Created: 2026-01-06 · Updated: 2026-01-08
func formatIssueMetadata(issue *types.Issue) string {
var lines []string
// Line 1: Owner/Assignee · Type
metaParts := []string{}
if issue.CreatedBy != "" {
metaParts = append(metaParts, fmt.Sprintf("Owner: %s", issue.CreatedBy))
}
if issue.Assignee != "" {
metaParts = append(metaParts, fmt.Sprintf("Assignee: %s", issue.Assignee))
}
// Type with semantic color
typeStr := string(issue.IssueType)
switch issue.IssueType {
case "epic":
typeStr = ui.TypeEpicStyle.Render("epic")
case "bug":
typeStr = ui.TypeBugStyle.Render("bug")
}
metaParts = append(metaParts, fmt.Sprintf("Type: %s", typeStr))
if len(metaParts) > 0 {
lines = append(lines, strings.Join(metaParts, " · "))
}
// Line 2: Created · Updated · Due/Defer
timeParts := []string{}
timeParts = append(timeParts, fmt.Sprintf("Created: %s", issue.CreatedAt.Format("2006-01-02")))
timeParts = append(timeParts, fmt.Sprintf("Updated: %s", issue.UpdatedAt.Format("2006-01-02")))
if issue.DueAt != nil {
timeParts = append(timeParts, fmt.Sprintf("Due: %s", issue.DueAt.Format("2006-01-02")))
}
if issue.DeferUntil != nil {
timeParts = append(timeParts, fmt.Sprintf("Deferred: %s", issue.DeferUntil.Format("2006-01-02")))
}
if len(timeParts) > 0 {
lines = append(lines, strings.Join(timeParts, " · "))
}
// Line 3: Close reason (if closed)
if issue.Status == types.StatusClosed && issue.CloseReason != "" {
lines = append(lines, ui.RenderMuted(fmt.Sprintf("Close reason: %s", issue.CloseReason)))
}
// Line 4: External ref (if exists)
if issue.ExternalRef != nil && *issue.ExternalRef != "" {
lines = append(lines, fmt.Sprintf("External: %s", *issue.ExternalRef))
}
return strings.Join(lines, "\n")
}
// formatDependencyLine formats a single dependency with semantic colors
// Closed items get entire row muted - the work is done, no need for attention
func formatDependencyLine(prefix string, dep *types.IssueWithDependencyMetadata) string {
// Status icon (always rendered with semantic color)
statusIcon := ui.GetStatusIcon(string(dep.Status))
// Closed items: mute entire row since the work is complete
if dep.Status == types.StatusClosed {
return fmt.Sprintf(" %s %s %s: %s %s",
prefix, statusIcon,
ui.RenderMuted(dep.ID),
ui.RenderMuted(dep.Title),
ui.RenderMuted(fmt.Sprintf("● P%d", dep.Priority)))
}
// Active items: ID with status color, priority with semantic color
style := ui.GetStatusStyle(string(dep.Status))
idStr := style.Render(dep.ID)
priorityTag := ui.RenderPriority(dep.Priority)
// Type indicator for epics/bugs
typeStr := ""
if dep.IssueType == "epic" {
typeStr = ui.TypeEpicStyle.Render("(EPIC)") + " "
} else if dep.IssueType == "bug" {
typeStr = ui.TypeBugStyle.Render("(BUG)") + " "
}
return fmt.Sprintf(" %s %s %s: %s%s %s", prefix, statusIcon, idStr, typeStr, dep.Title, priorityTag)
}
// formatSimpleDependencyLine formats a dependency without metadata (fallback)
// Closed items get entire row muted - the work is done, no need for attention
func formatSimpleDependencyLine(prefix string, dep *types.Issue) string {
statusIcon := ui.GetStatusIcon(string(dep.Status))
// Closed items: mute entire row since the work is complete
if dep.Status == types.StatusClosed {
return fmt.Sprintf(" %s %s %s: %s %s",
prefix, statusIcon,
ui.RenderMuted(dep.ID),
ui.RenderMuted(dep.Title),
ui.RenderMuted(fmt.Sprintf("● P%d", dep.Priority)))
}
// Active items: use semantic colors
style := ui.GetStatusStyle(string(dep.Status))
idStr := style.Render(dep.ID)
priorityTag := ui.RenderPriority(dep.Priority)
return fmt.Sprintf(" %s %s %s: %s %s", prefix, statusIcon, idStr, dep.Title, priorityTag)
}
// showIssueRefs displays issues that reference the given issue(s), grouped by relationship type
func showIssueRefs(ctx context.Context, args []string, resolvedIDs []string, routedArgs []string, jsonOut bool) {
// Collect all refs for all issues
allRefs := make(map[string][]*types.IssueWithDependencyMetadata)
// Process each issue
processIssue := func(issueID string, issueStore storage.Storage) error {
sqliteStore, ok := issueStore.(*sqlite.SQLiteStorage)
if !ok {
// Fallback: try to get dependents without metadata
dependents, err := issueStore.GetDependents(ctx, issueID)
if err != nil {
return err
}
for _, dep := range dependents {
allRefs[issueID] = append(allRefs[issueID], &types.IssueWithDependencyMetadata{Issue: *dep})
}
return nil
}
refs, err := sqliteStore.GetDependentsWithMetadata(ctx, issueID)
if err != nil {
return err
}
allRefs[issueID] = refs
return nil
}
// Handle routed IDs via direct mode
for _, id := range routedArgs {
result, err := resolveAndGetIssueWithRouting(ctx, store, id)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err)
continue
}
if result == nil || result.Issue == nil {
if result != nil {
result.Close()
}
fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
continue
}
if err := processIssue(result.ResolvedID, result.Store); err != nil {
fmt.Fprintf(os.Stderr, "Error getting refs for %s: %v\n", id, err)
}
result.Close()
}
// Handle resolved IDs (daemon mode)
if daemonClient != nil {
for _, id := range resolvedIDs {
// Need to open direct connection for GetDependentsWithMetadata
dbStore, err := sqlite.New(ctx, dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening database: %v\n", err)
continue
}
if err := processIssue(id, dbStore); err != nil {
fmt.Fprintf(os.Stderr, "Error getting refs for %s: %v\n", id, err)
}
_ = dbStore.Close()
}
} else {
// Direct mode - process each arg
for _, id := range args {
if containsStr(routedArgs, id) {
continue // Already processed above
}
result, err := resolveAndGetIssueWithRouting(ctx, store, id)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err)
continue
}
if result == nil || result.Issue == nil {
if result != nil {
result.Close()
}
fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
continue
}
if err := processIssue(result.ResolvedID, result.Store); err != nil {
fmt.Fprintf(os.Stderr, "Error getting refs for %s: %v\n", id, err)
}
result.Close()
}
}
// Output results
if jsonOut {
outputJSON(allRefs)
return
}
// Display refs grouped by issue and relationship type
for issueID, refs := range allRefs {
if len(refs) == 0 {
fmt.Printf("\n%s: No references found\n", ui.RenderAccent(issueID))
continue
}
fmt.Printf("\n%s References to %s:\n", ui.RenderAccent("📎"), issueID)
// Group refs by type
refsByType := make(map[types.DependencyType][]*types.IssueWithDependencyMetadata)
for _, ref := range refs {
refsByType[ref.DependencyType] = append(refsByType[ref.DependencyType], ref)
}
// Display each type
typeOrder := []types.DependencyType{
types.DepUntil, types.DepCausedBy, types.DepValidates,
types.DepBlocks, types.DepParentChild, types.DepRelatesTo,
types.DepTracks, types.DepDiscoveredFrom, types.DepRelated,
types.DepSupersedes, types.DepDuplicates, types.DepRepliesTo,
types.DepApprovedBy, types.DepAuthoredBy, types.DepAssignedTo,
}
// First show types in order, then any others
shown := make(map[types.DependencyType]bool)
for _, depType := range typeOrder {
if refs, ok := refsByType[depType]; ok {
displayRefGroup(depType, refs)
shown[depType] = true
}
}
// Show any remaining types
for depType, refs := range refsByType {
if !shown[depType] {
displayRefGroup(depType, refs)
}
}
fmt.Println()
}
}
// displayRefGroup displays a group of references with a given type
// Closed items get entire row muted - the work is done, no need for attention
func displayRefGroup(depType types.DependencyType, refs []*types.IssueWithDependencyMetadata) {
// Get emoji for type
emoji := getRefTypeEmoji(depType)
fmt.Printf("\n %s %s (%d):\n", emoji, depType, len(refs))
for _, ref := range refs {
// Closed items: mute entire row since the work is complete
if ref.Status == types.StatusClosed {
fmt.Printf(" %s: %s %s\n",
ui.RenderMuted(ref.ID),
ui.RenderMuted(ref.Title),
ui.RenderMuted(fmt.Sprintf("[P%d - %s]", ref.Priority, ref.Status)))
continue
}
// Active items: color ID based on status
var idStr string
switch ref.Status {
case types.StatusOpen:
idStr = ui.StatusOpenStyle.Render(ref.ID)
case types.StatusInProgress:
idStr = ui.StatusInProgressStyle.Render(ref.ID)
case types.StatusBlocked:
idStr = ui.StatusBlockedStyle.Render(ref.ID)
default:
idStr = ref.ID
}
fmt.Printf(" %s: %s [P%d - %s]\n", idStr, ref.Title, ref.Priority, ref.Status)
}
}
// getRefTypeEmoji returns an emoji for a dependency/reference type
func getRefTypeEmoji(depType types.DependencyType) string {
switch depType {
case types.DepUntil:
return "⏳" // Hourglass - waiting until
case types.DepCausedBy:
return "⚡" // Lightning - triggered by
case types.DepValidates:
return "✅" // Checkmark - validates
case types.DepBlocks:
return "🚫" // Blocked
case types.DepParentChild:
return "↳" // Child arrow
case types.DepRelatesTo, types.DepRelated:
return "↔" // Bidirectional
case types.DepTracks:
return "👁" // Watching
case types.DepDiscoveredFrom:
return "◊" // Diamond - discovered
case types.DepSupersedes:
return "⬆" // Upgrade
case types.DepDuplicates:
return "🔄" // Duplicate
case types.DepRepliesTo:
return "💬" // Chat
case types.DepApprovedBy:
return "👍" // Approved
case types.DepAuthoredBy:
return "✏" // Authored
case types.DepAssignedTo:
return "👤" // Assigned
default:
return "→" // Default arrow
}
}
// showIssueChildren displays only the children of the specified issue(s)
func showIssueChildren(ctx context.Context, args []string, resolvedIDs []string, routedArgs []string, jsonOut bool, shortMode bool) {
// Collect all children for all issues
allChildren := make(map[string][]*types.IssueWithDependencyMetadata)
// Process each issue to get its children
processIssue := func(issueID string, issueStore storage.Storage) error {
sqliteStore, ok := issueStore.(*sqlite.SQLiteStorage)
if !ok {
// Fallback: try to get dependents without metadata
dependents, err := issueStore.GetDependents(ctx, issueID)
if err != nil {
return err
}
// Filter for parent-child relationships (can't filter without metadata)
for _, dep := range dependents {
allChildren[issueID] = append(allChildren[issueID], &types.IssueWithDependencyMetadata{Issue: *dep})
}
return nil
}
// Get all dependents with metadata so we can filter for children
refs, err := sqliteStore.GetDependentsWithMetadata(ctx, issueID)
if err != nil {
return err
}
// Filter for only parent-child relationships
for _, ref := range refs {
if ref.DependencyType == types.DepParentChild {
allChildren[issueID] = append(allChildren[issueID], ref)
}
}
return nil
}
// Handle routed IDs via direct mode
for _, id := range routedArgs {
result, err := resolveAndGetIssueWithRouting(ctx, store, id)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err)
continue
}
if result == nil || result.Issue == nil {
if result != nil {
result.Close()
}
fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
continue
}
if err := processIssue(result.ResolvedID, result.Store); err != nil {
fmt.Fprintf(os.Stderr, "Error getting children for %s: %v\n", id, err)
}
result.Close()
}
// Handle resolved IDs (daemon mode)
if daemonClient != nil {
for _, id := range resolvedIDs {
// Need to open direct connection for GetDependentsWithMetadata
dbStore, err := sqlite.New(ctx, dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening database: %v\n", err)
continue
}
if err := processIssue(id, dbStore); err != nil {
fmt.Fprintf(os.Stderr, "Error getting children for %s: %v\n", id, err)
}
_ = dbStore.Close()
}
} else {
// Direct mode - process each arg
for _, id := range args {
if containsStr(routedArgs, id) {
continue // Already processed above
}
result, err := resolveAndGetIssueWithRouting(ctx, store, id)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err)
continue
}
if result == nil || result.Issue == nil {
if result != nil {
result.Close()
}
fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
continue
}
if err := processIssue(result.ResolvedID, result.Store); err != nil {
fmt.Fprintf(os.Stderr, "Error getting children for %s: %v\n", id, err)
}
result.Close()
}
}
// Output results
if jsonOut {
outputJSON(allChildren)
return
}
// Display children
for issueID, children := range allChildren {
if len(children) == 0 {
fmt.Printf("%s: No children found\n", ui.RenderAccent(issueID))
continue
}
fmt.Printf("%s Children of %s (%d):\n", ui.RenderAccent("↳"), issueID, len(children))
for _, child := range children {
if shortMode {
fmt.Printf(" %s\n", formatShortIssue(&child.Issue))
} else {
fmt.Println(formatDependencyLine("↳", child))
}
}
fmt.Println()
}
}
// containsStr checks if a string slice contains a value
func containsStr(slice []string, val string) bool {
for _, s := range slice {
if s == val {
return true
}
}
return false
}
func init() {
showCmd.Flags().Bool("thread", false, "Show full conversation thread (for messages)")
showCmd.Flags().Bool("short", false, "Show compact one-line output per issue")
showCmd.Flags().Bool("refs", false, "Show issues that reference this issue (reverse lookup)")
showCmd.Flags().Bool("children", false, "Show only the children of this issue")
showCmd.ValidArgsFunction = issueIDCompletion
rootCmd.AddCommand(showCmd)
}