Files
beads/cmd/bd/show.go
Steve Yegge a34f189153 Add 'last touched' issue tracking for update/close without ID
When no issue ID is provided to `bd update` or `bd close`, use the last
touched issue from the most recent create, update, show, or close operation.

This addresses the common workflow where you create an issue and then
immediately want to add more details (like changing priority from P2 to P4)
without re-typing the issue ID.

Implementation:
- New file last_touched.go with Get/Set/Clear functions
- Store last touched ID in .beads/last-touched (gitignored)
- Track on create, update, show, and close operations
- Allow update/close with zero args to use last touched

(bd-s2t)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 16:58:10 -08:00

802 lines
25 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")
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
}
// 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" + strings.Repeat("─", 60))
}
fmt.Printf("\n%s: %s\n", ui.RenderAccent(issue.ID), issue.Title)
fmt.Printf("Status: %s\n", issue.Status)
fmt.Printf("Priority: P%d\n", issue.Priority)
fmt.Printf("Type: %s\n", issue.IssueType)
if issue.Description != "" {
fmt.Printf("\nDescription:\n%s\n", 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" + strings.Repeat("─", 60))
}
displayIdx++
// Format output (same as direct mode below)
tierEmoji := ""
statusSuffix := ""
switch issue.CompactionLevel {
case 1:
tierEmoji = " 🗜️"
statusSuffix = " (compacted L1)"
case 2:
tierEmoji = " 📦"
statusSuffix = " (compacted L2)"
}
fmt.Printf("\n%s: %s%s\n", ui.RenderAccent(issue.ID), issue.Title, tierEmoji)
fmt.Printf("Status: %s%s\n", issue.Status, statusSuffix)
if issue.CloseReason != "" {
fmt.Printf("Close reason: %s\n", issue.CloseReason)
}
fmt.Printf("Priority: P%d\n", issue.Priority)
fmt.Printf("Type: %s\n", issue.IssueType)
if issue.Assignee != "" {
fmt.Printf("Assignee: %s\n", issue.Assignee)
}
if issue.EstimatedMinutes != nil {
fmt.Printf("Estimated: %d minutes\n", *issue.EstimatedMinutes)
}
fmt.Printf("Created: %s\n", issue.CreatedAt.Format("2006-01-02 15:04"))
if issue.CreatedBy != "" {
fmt.Printf("Created by: %s\n", issue.CreatedBy)
}
fmt.Printf("Updated: %s\n", issue.UpdatedAt.Format("2006-01-02 15:04"))
// Show compaction status
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("📊 Original: %d bytes | Compressed: %d bytes (%.0f%% reduction)\n",
issue.OriginalSize, currentSize, reduction)
}
}
tierEmoji2 := "🗜️"
if issue.CompactionLevel == 2 {
tierEmoji2 = "📦"
}
compactedDate := ""
if issue.CompactedAt != nil {
compactedDate = issue.CompactedAt.Format("2006-01-02")
}
fmt.Printf("%s Compacted: %s (Tier %d)\n", tierEmoji2, compactedDate, issue.CompactionLevel)
}
if issue.Description != "" {
fmt.Printf("\nDescription:\n%s\n", issue.Description)
}
if issue.Design != "" {
fmt.Printf("\nDesign:\n%s\n", issue.Design)
}
if issue.Notes != "" {
fmt.Printf("\nNotes:\n%s\n", issue.Notes)
}
if issue.AcceptanceCriteria != "" {
fmt.Printf("\nAcceptance Criteria:\n%s\n", issue.AcceptanceCriteria)
}
if len(details.Labels) > 0 {
fmt.Printf("\nLabels: %v\n", details.Labels)
}
if len(details.Dependencies) > 0 {
fmt.Printf("\nDepends on (%d):\n", len(details.Dependencies))
for _, dep := range details.Dependencies {
fmt.Printf(" → %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
}
}
if len(details.Dependents) > 0 {
// Group by dependency type for clarity
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("\nChildren (%d):\n", len(children))
for _, dep := range children {
fmt.Printf(" ↳ %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
}
}
if len(blocks) > 0 {
fmt.Printf("\nBlocks (%d):\n", len(blocks))
for _, dep := range blocks {
fmt.Printf(" ← %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
}
}
if len(related) > 0 {
fmt.Printf("\nRelated (%d):\n", len(related))
for _, dep := range related {
fmt.Printf(" ↔ %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
}
}
if len(discovered) > 0 {
fmt.Printf("\nDiscovered (%d):\n", len(discovered))
for _, dep := range discovered {
fmt.Printf(" ◊ %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
}
}
}
if len(details.Comments) > 0 {
fmt.Printf("\nComments (%d):\n", len(details.Comments))
for _, comment := range details.Comments {
fmt.Printf(" [%s] %s\n", comment.Author, comment.CreatedAt.Format("2006-01-02 15:04"))
commentLines := strings.Split(comment.Text, "\n")
for _, line := range commentLines {
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" + strings.Repeat("─", 60))
}
// Add compaction emoji to title line
tierEmoji := ""
statusSuffix := ""
switch issue.CompactionLevel {
case 1:
tierEmoji = " 🗜️"
statusSuffix = " (compacted L1)"
case 2:
tierEmoji = " 📦"
statusSuffix = " (compacted L2)"
}
fmt.Printf("\n%s: %s%s\n", ui.RenderAccent(issue.ID), issue.Title, tierEmoji)
fmt.Printf("Status: %s%s\n", issue.Status, statusSuffix)
if issue.CloseReason != "" {
fmt.Printf("Close reason: %s\n", issue.CloseReason)
}
fmt.Printf("Priority: P%d\n", issue.Priority)
fmt.Printf("Type: %s\n", issue.IssueType)
if issue.Assignee != "" {
fmt.Printf("Assignee: %s\n", issue.Assignee)
}
if issue.EstimatedMinutes != nil {
fmt.Printf("Estimated: %d minutes\n", *issue.EstimatedMinutes)
}
fmt.Printf("Created: %s\n", issue.CreatedAt.Format("2006-01-02 15:04"))
if issue.CreatedBy != "" {
fmt.Printf("Created by: %s\n", issue.CreatedBy)
}
fmt.Printf("Updated: %s\n", issue.UpdatedAt.Format("2006-01-02 15:04"))
// Show compaction status footer
if issue.CompactionLevel > 0 {
tierEmoji := "🗜️"
if issue.CompactionLevel == 2 {
tierEmoji = "📦"
}
tierName := fmt.Sprintf("Tier %d", issue.CompactionLevel)
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("📊 Original: %d bytes | Compressed: %d bytes (%.0f%% reduction)\n",
issue.OriginalSize, currentSize, reduction)
}
}
compactedDate := ""
if issue.CompactedAt != nil {
compactedDate = issue.CompactedAt.Format("2006-01-02")
}
fmt.Printf("%s Compacted: %s (%s)\n", tierEmoji, compactedDate, tierName)
}
if issue.Description != "" {
fmt.Printf("\nDescription:\n%s\n", issue.Description)
}
if issue.Design != "" {
fmt.Printf("\nDesign:\n%s\n", issue.Design)
}
if issue.Notes != "" {
fmt.Printf("\nNotes:\n%s\n", issue.Notes)
}
if issue.AcceptanceCriteria != "" {
fmt.Printf("\nAcceptance Criteria:\n%s\n", issue.AcceptanceCriteria)
}
// Show labels
labels, _ := issueStore.GetLabels(ctx, issue.ID)
if len(labels) > 0 {
fmt.Printf("\nLabels: %v\n", labels)
}
// Show dependencies
deps, _ := issueStore.GetDependencies(ctx, issue.ID)
if len(deps) > 0 {
fmt.Printf("\nDepends on (%d):\n", len(deps))
for _, dep := range deps {
fmt.Printf(" → %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
}
}
// 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("\nChildren (%d):\n", len(children))
for _, dep := range children {
fmt.Printf(" ↳ %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
}
}
if len(blocks) > 0 {
fmt.Printf("\nBlocks (%d):\n", len(blocks))
for _, dep := range blocks {
fmt.Printf(" ← %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
}
}
if len(related) > 0 {
fmt.Printf("\nRelated (%d):\n", len(related))
for _, dep := range related {
fmt.Printf(" ↔ %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
}
}
if len(discovered) > 0 {
fmt.Printf("\nDiscovered (%d):\n", len(discovered))
for _, dep := range discovered {
fmt.Printf(" ◊ %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
}
}
}
} else {
// Fallback for non-SQLite storage
dependents, _ := issueStore.GetDependents(ctx, issue.ID)
if len(dependents) > 0 {
fmt.Printf("\nBlocks (%d):\n", len(dependents))
for _, dep := range dependents {
fmt.Printf(" ← %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
}
}
}
// Show comments
comments, _ := issueStore.GetIssueComments(ctx, issue.ID)
if len(comments) > 0 {
fmt.Printf("\nComments (%d):\n", len(comments))
for _, comment := range comments {
fmt.Printf(" [%s at %s]\n %s\n\n", comment.Author, comment.CreatedAt.Format("2006-01-02 15:04"), comment.Text)
}
}
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: <id> [<status>] P<priority> <type>: <title>
func formatShortIssue(issue *types.Issue) string {
return fmt.Sprintf("%s [%s] P%d %s: %s",
issue.ID, issue.Status, issue.Priority, issue.IssueType, issue.Title)
}
// 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
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 {
// 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)
case types.StatusClosed:
idStr = ui.StatusClosedStyle.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
}
}
// 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)")
rootCmd.AddCommand(showCmd)
}