feat: add refs field for cross-references with relationship types (bd-irah)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
216
cmd/bd/show.go
216
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user