From a958c834226b61d2463b304da1c733b3a8454491 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 26 Dec 2025 23:47:42 -0800 Subject: [PATCH] feat: auto-resolve cross-rig IDs in bd dep add (bd-lfiu) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `bd dep add` fails to resolve the dependency ID locally, it now checks routes.jsonl for a matching prefix and auto-converts to an external reference format (external::). This allows simpler syntax like: bd dep add gt-xyz bd-abc Instead of the verbose: bd dep add gt-xyz external:beads:bd-abc New functions in routing package: - ExtractProjectFromPath: Gets project name from route path - ResolveToExternalRef: Converts foreign ID to external ref using routes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/dep.go | 31 +++++++++++++++--- internal/routing/routes.go | 43 +++++++++++++++++++++++++ internal/routing/routing_test.go | 54 ++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 4 deletions(-) diff --git a/cmd/bd/dep.go b/cmd/bd/dep.go index c2657bdb..fb283d19 100644 --- a/cmd/bd/dep.go +++ b/cmd/bd/dep.go @@ -5,9 +5,11 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "strings" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/routing" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" @@ -15,6 +17,14 @@ import ( "github.com/steveyegge/beads/internal/utils" ) +// getBeadsDir returns the .beads directory path, derived from the global dbPath. +func getBeadsDir() string { + if dbPath != "" { + return filepath.Dir(dbPath) + } + return "" +} + // isChildOf returns true if childID is a hierarchical child of parentID. // For example, "bd-abc.1" is a child of "bd-abc", and "bd-abc.1.2" is a child of "bd-abc.1". func isChildOf(childID, parentID string) bool { @@ -88,9 +98,15 @@ Examples: resolveArgs = &rpc.ResolveIDArgs{ID: args[1]} resp, err = daemonClient.ResolveID(resolveArgs) if err != nil { - FatalErrorRespectJSON("resolving dependency ID %s: %v", args[1], err) - } - if err := json.Unmarshal(resp.Data, &toID); err != nil { + // Resolution failed - try auto-converting to external ref (bd-lfiu) + beadsDir := getBeadsDir() + if extRef := routing.ResolveToExternalRef(args[1], beadsDir); extRef != "" { + toID = extRef + isExternalRef = true + } else { + FatalErrorRespectJSON("resolving dependency ID %s: %v", args[1], err) + } + } else if err := json.Unmarshal(resp.Data, &toID); err != nil { FatalErrorRespectJSON("unmarshaling resolved ID: %v", err) } } @@ -111,7 +127,14 @@ Examples: } else { toID, err = utils.ResolvePartialID(ctx, store, args[1]) if err != nil { - FatalErrorRespectJSON("resolving dependency ID %s: %v", args[1], err) + // Resolution failed - try auto-converting to external ref (bd-lfiu) + beadsDir := getBeadsDir() + if extRef := routing.ResolveToExternalRef(args[1], beadsDir); extRef != "" { + toID = extRef + isExternalRef = true + } else { + FatalErrorRespectJSON("resolving dependency ID %s: %v", args[1], err) + } } } } diff --git a/internal/routing/routes.go b/internal/routing/routes.go index eae2d4ad..59f362ca 100644 --- a/internal/routing/routes.go +++ b/internal/routing/routes.go @@ -67,6 +67,49 @@ func ExtractPrefix(id string) string { return id[:idx+1] // Include the hyphen } +// ExtractProjectFromPath extracts the project name from a route path. +// For "beads/mayor/rig", returns "beads". +// For "gastown/crew/max", returns "gastown". +func ExtractProjectFromPath(path string) string { + // Get the first component of the path + parts := strings.Split(path, "/") + if len(parts) > 0 && parts[0] != "" { + return parts[0] + } + return "" +} + +// ResolveToExternalRef attempts to convert a foreign issue ID to an external reference +// using routes.jsonl for prefix-based routing. +// +// If the ID's prefix matches a route, returns "external::". +// Otherwise, returns empty string (no route found). +// +// Example: If routes.jsonl has {"prefix": "bd-", "path": "beads/mayor/rig"} +// then ResolveToExternalRef("bd-abc", beadsDir) returns "external:beads:bd-abc" +func ResolveToExternalRef(id, beadsDir string) string { + routes, err := LoadRoutes(beadsDir) + if err != nil || len(routes) == 0 { + return "" + } + + prefix := ExtractPrefix(id) + if prefix == "" { + return "" + } + + for _, route := range routes { + if route.Prefix == prefix { + project := ExtractProjectFromPath(route.Path) + if project != "" { + return fmt.Sprintf("external:%s:%s", project, id) + } + } + } + + return "" +} + // ResolveBeadsDirForID determines which beads directory contains the given issue ID. // It first checks the local beads directory, then consults routes.jsonl for prefix-based routing. // diff --git a/internal/routing/routing_test.go b/internal/routing/routing_test.go index 97170e88..13e19906 100644 --- a/internal/routing/routing_test.go +++ b/internal/routing/routing_test.go @@ -88,3 +88,57 @@ func TestDetectUserRole_Fallback(t *testing.T) { t.Errorf("DetectUserRole() = %v, want %v (fallback)", role, Contributor) } } + +func TestExtractPrefix(t *testing.T) { + tests := []struct { + id string + want string + }{ + {"gt-abc123", "gt-"}, + {"bd-xyz", "bd-"}, + {"hq-1234", "hq-"}, + {"abc123", ""}, // No hyphen + {"", ""}, // Empty string + {"-abc", "-"}, // Starts with hyphen + } + + for _, tt := range tests { + t.Run(tt.id, func(t *testing.T) { + got := ExtractPrefix(tt.id) + if got != tt.want { + t.Errorf("ExtractPrefix(%q) = %q, want %q", tt.id, got, tt.want) + } + }) + } +} + +func TestExtractProjectFromPath(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"beads/mayor/rig", "beads"}, + {"gastown/crew/max", "gastown"}, + {"simple", "simple"}, + {"", ""}, + {"/absolute/path", ""}, // Starts with /, first component is empty + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := ExtractProjectFromPath(tt.path) + if got != tt.want { + t.Errorf("ExtractProjectFromPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestResolveToExternalRef(t *testing.T) { + // This test is limited since it requires a routes.jsonl file + // Just test that it returns empty string for nonexistent directory + got := ResolveToExternalRef("bd-abc", "/nonexistent/path") + if got != "" { + t.Errorf("ResolveToExternalRef() = %q, want empty string for nonexistent path", got) + } +}