feat: Add prefix-based routing with routes.jsonl (bd-9gvf)

Enables cross-repo issue lookups via routes.jsonl configuration.
Running `bd show gt-xyz` from ~/gt now routes to the correct beads
directory based on the issue ID prefix.

- Add internal/routing/routes.go with routing logic
- Add cmd/bd/routed.go with routed storage helpers
- Update show command to use routed resolution in direct mode
- Support redirect files for canonical database locations
- Debug output available via BD_DEBUG_ROUTING=1

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-26 14:36:20 -08:00
parent 5c8d45aa49
commit 1583c57374
3 changed files with 405 additions and 25 deletions

166
cmd/bd/routed.go Normal file
View File

@@ -0,0 +1,166 @@
package main
import (
"context"
"path/filepath"
"github.com/steveyegge/beads/internal/routing"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils"
)
// RoutedResult contains the result of a routed issue lookup
type RoutedResult struct {
Issue *types.Issue
Store storage.Storage // The store that contains this issue (may be routed)
Routed bool // true if the issue was found via routing
ResolvedID string // The resolved (full) issue ID
closeFn func() // Function to close routed storage (if any)
}
// Close closes any routed storage. Safe to call if Routed is false.
func (r *RoutedResult) Close() {
if r.closeFn != nil {
r.closeFn()
}
}
// resolveAndGetIssueWithRouting resolves a partial ID and gets the issue,
// using routes.jsonl for prefix-based routing if needed.
// This enables cross-repo issue lookups (e.g., `bd show gt-xyz` from ~/gt).
//
// The resolution happens in the correct store based on the ID prefix.
// Returns a RoutedResult containing the issue, resolved ID, and the store to use.
// The caller MUST call result.Close() when done to release any routed storage.
func resolveAndGetIssueWithRouting(ctx context.Context, localStore storage.Storage, id string) (*RoutedResult, error) {
// Step 1: Check if routing is needed based on ID prefix
if dbPath == "" {
// No routing without a database path - use local store
return resolveAndGetFromStore(ctx, localStore, id, false)
}
beadsDir := filepath.Dir(dbPath)
routedStorage, err := routing.GetRoutedStorageForID(ctx, id, beadsDir)
if err != nil {
return nil, err
}
if routedStorage != nil {
// Step 2: Resolve and get from routed store
result, err := resolveAndGetFromStore(ctx, routedStorage.Storage, id, true)
if err != nil {
routedStorage.Close()
return nil, err
}
if result != nil {
result.closeFn = func() { routedStorage.Close() }
return result, nil
}
routedStorage.Close()
}
// Step 3: Fall back to local store
return resolveAndGetFromStore(ctx, localStore, id, false)
}
// resolveAndGetFromStore resolves a partial ID and gets the issue from a specific store.
func resolveAndGetFromStore(ctx context.Context, s storage.Storage, id string, routed bool) (*RoutedResult, error) {
// First, resolve the partial ID
resolvedID, err := utils.ResolvePartialID(ctx, s, id)
if err != nil {
return nil, err
}
// Then get the issue
issue, err := s.GetIssue(ctx, resolvedID)
if err != nil {
return nil, err
}
if issue == nil {
return nil, nil
}
return &RoutedResult{
Issue: issue,
Store: s,
Routed: routed,
ResolvedID: resolvedID,
}, nil
}
// getIssueWithRouting tries to get an issue from the local store first,
// then falls back to checking routes.jsonl for prefix-based routing.
// This enables cross-repo issue lookups (e.g., `bd show gt-xyz` from ~/gt).
//
// Returns a RoutedResult containing the issue and the store to use for related queries.
// The caller MUST call result.Close() when done to release any routed storage.
func getIssueWithRouting(ctx context.Context, localStore storage.Storage, id string) (*RoutedResult, error) {
// Step 1: Try local store first (current behavior)
issue, err := localStore.GetIssue(ctx, id)
if err == nil && issue != nil {
return &RoutedResult{
Issue: issue,
Store: localStore,
Routed: false,
ResolvedID: id,
}, nil
}
// Step 2: Check routes.jsonl for prefix-based routing
if dbPath == "" {
// No routing without a database path - return original result
return &RoutedResult{
Issue: issue,
Store: localStore,
Routed: false,
ResolvedID: id,
}, err
}
beadsDir := filepath.Dir(dbPath)
routedStorage, routeErr := routing.GetRoutedStorageForID(ctx, id, beadsDir)
if routeErr != nil || routedStorage == nil {
// No routing found or error - return original result
return &RoutedResult{
Issue: issue,
Store: localStore,
Routed: false,
ResolvedID: id,
}, err
}
// Step 3: Try the routed storage
routedIssue, routedErr := routedStorage.Storage.GetIssue(ctx, id)
if routedErr != nil || routedIssue == nil {
routedStorage.Close()
// Return the original error if routing also failed
if err != nil {
return nil, err
}
return nil, routedErr
}
// Return the issue with the routed store
return &RoutedResult{
Issue: routedIssue,
Store: routedStorage.Storage,
Routed: true,
ResolvedID: id,
closeFn: func() {
routedStorage.Close()
},
}, nil
}
// getRoutedStoreForID returns a storage connection for an issue ID if routing is needed.
// Returns nil if no routing is needed (issue should be in local store).
// The caller is responsible for closing the returned storage.
func getRoutedStoreForID(ctx context.Context, id string) (*routing.RoutedStorage, error) {
if dbPath == "" {
return nil, nil
}
beadsDir := filepath.Dir(dbPath)
return routing.GetRoutedStorageForID(ctx, id, beadsDir)
}

View File

@@ -37,7 +37,7 @@ var showCmd = &cobra.Command{
}
}
// Resolve partial IDs first
// Resolve partial IDs first (daemon mode only - direct mode uses routed resolution)
var resolvedIDs []string
if daemonClient != nil {
// In daemon mode, resolve via RPC
@@ -53,19 +53,25 @@ var showCmd = &cobra.Command{
}
resolvedIDs = append(resolvedIDs, resolvedID)
}
} else {
// In direct mode, resolve via storage
var err error
resolvedIDs, err = utils.ResolvePartialIDs(ctx, store, args)
if err != nil {
FatalErrorRespectJSON("%v", err)
}
}
// Note: Direct mode uses resolveAndGetIssueWithRouting for prefix-based routing
// Handle --thread flag: show full conversation thread
if showThread && len(resolvedIDs) > 0 {
showMessageThread(ctx, resolvedIDs[0], jsonOutput)
return
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
}
}
}
// If daemon is running, use RPC
@@ -246,18 +252,24 @@ var showCmd = &cobra.Command{
return
}
// Direct mode
// Direct mode - use routed resolution for cross-repo lookups
allDetails := []interface{}{}
for idx, id := range resolvedIDs {
issue, err := store.GetIssue(ctx, id)
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 result != nil {
defer result.Close()
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", id, err)
continue
}
if issue == nil {
if result == nil || result.Issue == nil {
fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
continue
}
issue := result.Issue
issueStore := result.Store // Use the store that contains this issue
if jsonOutput {
// Include labels, dependencies (with metadata), dependents (with metadata), and comments in JSON output
@@ -269,25 +281,25 @@ var showCmd = &cobra.Command{
Comments []*types.Comment `json:"comments,omitempty"`
}
details := &IssueDetails{Issue: issue}
details.Labels, _ = store.GetLabels(ctx, issue.ID)
details.Labels, _ = issueStore.GetLabels(ctx, issue.ID)
// Get dependencies with metadata (dependency_type field)
if sqliteStore, ok := store.(*sqlite.SQLiteStorage); ok {
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, _ := store.GetDependencies(ctx, issue.ID)
deps, _ := issueStore.GetDependencies(ctx, issue.ID)
for _, dep := range deps {
details.Dependencies = append(details.Dependencies, &types.IssueWithDependencyMetadata{Issue: *dep})
}
dependents, _ := store.GetDependents(ctx, issue.ID)
dependents, _ := issueStore.GetDependents(ctx, issue.ID)
for _, dependent := range dependents {
details.Dependents = append(details.Dependents, &types.IssueWithDependencyMetadata{Issue: *dependent})
}
}
details.Comments, _ = store.GetIssueComments(ctx, issue.ID)
details.Comments, _ = issueStore.GetIssueComments(ctx, issue.ID)
allDetails = append(allDetails, details)
continue
}
@@ -366,13 +378,13 @@ var showCmd = &cobra.Command{
}
// Show labels
labels, _ := store.GetLabels(ctx, issue.ID)
labels, _ := issueStore.GetLabels(ctx, issue.ID)
if len(labels) > 0 {
fmt.Printf("\nLabels: %v\n", labels)
}
// Show dependencies
deps, _ := store.GetDependencies(ctx, issue.ID)
deps, _ := issueStore.GetDependencies(ctx, issue.ID)
if len(deps) > 0 {
fmt.Printf("\nDepends on (%d):\n", len(deps))
for _, dep := range deps {
@@ -382,7 +394,7 @@ var showCmd = &cobra.Command{
// Show dependents - grouped by dependency type for clarity
// Use GetDependentsWithMetadata to get the dependency type
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
sqliteStore, ok := issueStore.(*sqlite.SQLiteStorage)
if ok {
dependentsWithMeta, _ := sqliteStore.GetDependentsWithMetadata(ctx, issue.ID)
if len(dependentsWithMeta) > 0 {
@@ -430,7 +442,7 @@ var showCmd = &cobra.Command{
}
} else {
// Fallback for non-SQLite storage
dependents, _ := store.GetDependents(ctx, issue.ID)
dependents, _ := issueStore.GetDependents(ctx, issue.ID)
if len(dependents) > 0 {
fmt.Printf("\nBlocks (%d):\n", len(dependents))
for _, dep := range dependents {
@@ -440,7 +452,7 @@ var showCmd = &cobra.Command{
}
// Show comments
comments, _ := store.GetIssueComments(ctx, issue.ID)
comments, _ := issueStore.GetIssueComments(ctx, issue.ID)
if len(comments) > 0 {
fmt.Printf("\nComments (%d):\n", len(comments))
for _, comment := range comments {