From 407e75b363a439e09ab8ae85bc14dc3084745a59 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 30 Dec 2025 15:51:54 -0800 Subject: [PATCH] feat: add refs field for cross-references with relationship types (bd-irah) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add new DependencyType constants: until, caused-by, validates - Add --refs flag to bd show for reverse reference lookups - Group refs by type with appropriate emojis - Update tests for new dependency types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/dep.go | 2 +- cmd/bd/show.go | 216 +++++++++++++++++++++++++++++++++++ internal/types/types.go | 8 +- internal/types/types_test.go | 8 ++ 4 files changed, 232 insertions(+), 2 deletions(-) diff --git a/cmd/bd/dep.go b/cmd/bd/dep.go index fe8c26ad..fe6d9aa9 100644 --- a/cmd/bd/dep.go +++ b/cmd/bd/dep.go @@ -974,7 +974,7 @@ func ParseExternalRef(ref string) (project, capability string) { } func init() { - depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|tracks|related|parent-child|discovered-from)") + depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|tracks|related|parent-child|discovered-from|until|caused-by|validates|relates-to|supersedes)") // Note: --json flag is defined as a persistent flag in main.go, not here // Note: --json flag is defined as a persistent flag in main.go, not here diff --git a/cmd/bd/show.go b/cmd/bd/show.go index 665a3f24..aa87c00f 100644 --- a/cmd/bd/show.go +++ b/cmd/bd/show.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "os" @@ -8,6 +9,7 @@ import ( "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" @@ -21,6 +23,7 @@ var showCmd = &cobra.Command{ 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 @@ -74,6 +77,12 @@ var showCmd = &cobra.Command{ } } + // 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{}{} @@ -566,8 +575,215 @@ func formatShortIssue(issue *types.Issue) string { 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) } diff --git a/internal/types/types.go b/internal/types/types.go index a4d9d100..09cd2143 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -523,6 +523,11 @@ const ( // Convoy tracking (non-blocking cross-project references) DepTracks DependencyType = "tracks" // Convoy → issue tracking (non-blocking) + + // Reference types (cross-referencing without blocking) + DepUntil DependencyType = "until" // Active until target closes (e.g., muted until issue resolved) + DepCausedBy DependencyType = "caused-by" // Triggered by target (audit trail) + DepValidates DependencyType = "validates" // Approval/validation relationship ) // IsValid checks if the dependency type value is valid. @@ -538,7 +543,8 @@ func (d DependencyType) IsWellKnown() bool { switch d { case DepBlocks, DepParentChild, DepConditionalBlocks, DepWaitsFor, DepRelated, DepDiscoveredFrom, DepRepliesTo, DepRelatesTo, DepDuplicates, DepSupersedes, - DepAuthoredBy, DepAssignedTo, DepApprovedBy, DepTracks: + DepAuthoredBy, DepAssignedTo, DepApprovedBy, DepTracks, + DepUntil, DepCausedBy, DepValidates: return true } return false diff --git a/internal/types/types_test.go b/internal/types/types_test.go index 40906aea..2f2be8da 100644 --- a/internal/types/types_test.go +++ b/internal/types/types_test.go @@ -522,6 +522,10 @@ func TestDependencyTypeIsWellKnown(t *testing.T) { {DepAuthoredBy, true}, {DepAssignedTo, true}, {DepApprovedBy, true}, + {DepTracks, true}, + {DepUntil, true}, + {DepCausedBy, true}, + {DepValidates, true}, {DependencyType("custom-type"), false}, {DependencyType("unknown"), false}, } @@ -553,6 +557,10 @@ func TestDependencyTypeAffectsReadyWork(t *testing.T) { {DepAuthoredBy, false}, {DepAssignedTo, false}, {DepApprovedBy, false}, + {DepTracks, false}, + {DepUntil, false}, + {DepCausedBy, false}, + {DepValidates, false}, {DependencyType("custom-type"), false}, }