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) }