feat: auto-resolve cross-rig IDs in bd dep add (bd-lfiu)
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:<project>:<id>). 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 <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/beads/internal/routing"
|
||||||
"github.com/steveyegge/beads/internal/rpc"
|
"github.com/steveyegge/beads/internal/rpc"
|
||||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
@@ -15,6 +17,14 @@ import (
|
|||||||
"github.com/steveyegge/beads/internal/utils"
|
"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.
|
// 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".
|
// 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 {
|
func isChildOf(childID, parentID string) bool {
|
||||||
@@ -88,9 +98,15 @@ Examples:
|
|||||||
resolveArgs = &rpc.ResolveIDArgs{ID: args[1]}
|
resolveArgs = &rpc.ResolveIDArgs{ID: args[1]}
|
||||||
resp, err = daemonClient.ResolveID(resolveArgs)
|
resp, err = daemonClient.ResolveID(resolveArgs)
|
||||||
if err != nil {
|
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 err := json.Unmarshal(resp.Data, &toID); err != nil {
|
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)
|
FatalErrorRespectJSON("unmarshaling resolved ID: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,7 +127,14 @@ Examples:
|
|||||||
} else {
|
} else {
|
||||||
toID, err = utils.ResolvePartialID(ctx, store, args[1])
|
toID, err = utils.ResolvePartialID(ctx, store, args[1])
|
||||||
if err != nil {
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,49 @@ func ExtractPrefix(id string) string {
|
|||||||
return id[:idx+1] // Include the hyphen
|
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:<project>:<id>".
|
||||||
|
// 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.
|
// 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.
|
// It first checks the local beads directory, then consults routes.jsonl for prefix-based routing.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -88,3 +88,57 @@ func TestDetectUserRole_Fallback(t *testing.T) {
|
|||||||
t.Errorf("DetectUserRole() = %v, want %v (fallback)", role, Contributor)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user