From 1583c57374ecb2711372f271dac3f2a68d526703 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 26 Dec 2025 14:36:20 -0800 Subject: [PATCH] feat: Add prefix-based routing with routes.jsonl (bd-9gvf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/bd/routed.go | 166 ++++++++++++++++++++++++++++++ cmd/bd/show.go | 62 +++++++----- internal/routing/routes.go | 202 +++++++++++++++++++++++++++++++++++++ 3 files changed, 405 insertions(+), 25 deletions(-) create mode 100644 cmd/bd/routed.go create mode 100644 internal/routing/routes.go diff --git a/cmd/bd/routed.go b/cmd/bd/routed.go new file mode 100644 index 00000000..26c0791b --- /dev/null +++ b/cmd/bd/routed.go @@ -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) +} diff --git a/cmd/bd/show.go b/cmd/bd/show.go index ac17541b..b23a8a61 100644 --- a/cmd/bd/show.go +++ b/cmd/bd/show.go @@ -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 { diff --git a/internal/routing/routes.go b/internal/routing/routes.go new file mode 100644 index 00000000..eae2d4ad --- /dev/null +++ b/internal/routing/routes.go @@ -0,0 +1,202 @@ +package routing + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/steveyegge/beads/internal/storage" + "github.com/steveyegge/beads/internal/storage/sqlite" +) + +// RoutesFileName is the name of the routes configuration file +const RoutesFileName = "routes.jsonl" + +// Route represents a prefix-to-path routing rule +type Route struct { + Prefix string `json:"prefix"` // Issue ID prefix (e.g., "gt-") + Path string `json:"path"` // Relative path to .beads directory +} + +// LoadRoutes loads routes from routes.jsonl in the given beads directory. +// Returns an empty slice if the file doesn't exist. +func LoadRoutes(beadsDir string) ([]Route, error) { + routesPath := filepath.Join(beadsDir, RoutesFileName) + file, err := os.Open(routesPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil // No routes file is not an error + } + return nil, err + } + defer file.Close() + + var routes []Route + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue // Skip empty lines and comments + } + + var route Route + if err := json.Unmarshal([]byte(line), &route); err != nil { + continue // Skip malformed lines + } + if route.Prefix != "" && route.Path != "" { + routes = append(routes, route) + } + } + + return routes, scanner.Err() +} + +// ExtractPrefix extracts the prefix from an issue ID. +// For "gt-abc123", returns "gt-". +// For "bd-abc123", returns "bd-". +// Returns empty string if no prefix found. +func ExtractPrefix(id string) string { + idx := strings.Index(id, "-") + if idx < 0 { + return "" + } + return id[:idx+1] // Include the hyphen +} + +// 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. +// +// Parameters: +// - ctx: context for database operations +// - id: the issue ID to look up +// - currentBeadsDir: the current/local .beads directory path +// +// Returns: +// - beadsDir: the resolved .beads directory path +// - routed: true if the ID was routed to a different directory +// - err: any error encountered +func ResolveBeadsDirForID(ctx context.Context, id, currentBeadsDir string) (string, bool, error) { + // Step 1: Check for routes.jsonl FIRST based on ID prefix + // This allows prefix-based routing without needing to check the local store + routes, loadErr := LoadRoutes(currentBeadsDir) + if loadErr == nil && len(routes) > 0 { + prefix := ExtractPrefix(id) + if prefix != "" { + for _, route := range routes { + if route.Prefix == prefix { + // Found a matching route - resolve the path + projectRoot := filepath.Dir(currentBeadsDir) + targetPath := filepath.Join(projectRoot, route.Path, ".beads") + + // Follow redirect if present + targetPath = resolveRedirect(targetPath) + + // Verify the target exists + if info, err := os.Stat(targetPath); err == nil && info.IsDir() { + // Debug logging + if os.Getenv("BD_DEBUG_ROUTING") != "" { + fmt.Fprintf(os.Stderr, "[routing] ID %s matched prefix %s -> %s\n", id, prefix, targetPath) + } + return targetPath, true, nil + } + } + } + } + } + + // Step 2: No route matched or no routes file - use local store + return currentBeadsDir, false, nil +} + +// resolveRedirect checks for a redirect file in the beads directory +// and resolves the redirect path if present. +func resolveRedirect(beadsDir string) string { + redirectFile := filepath.Join(beadsDir, "redirect") + data, err := os.ReadFile(redirectFile) + if err != nil { + if os.Getenv("BD_DEBUG_ROUTING") != "" { + fmt.Fprintf(os.Stderr, "[routing] No redirect file at %s: %v\n", redirectFile, err) + } + return beadsDir // No redirect + } + + redirectPath := strings.TrimSpace(string(data)) + if os.Getenv("BD_DEBUG_ROUTING") != "" { + fmt.Fprintf(os.Stderr, "[routing] Read redirect: %q from %s\n", redirectPath, redirectFile) + } + if redirectPath == "" { + return beadsDir + } + + // Handle relative paths + if !filepath.IsAbs(redirectPath) { + redirectPath = filepath.Join(beadsDir, redirectPath) + } + + // Clean and resolve the path + redirectPath = filepath.Clean(redirectPath) + if os.Getenv("BD_DEBUG_ROUTING") != "" { + fmt.Fprintf(os.Stderr, "[routing] Resolved redirect path: %s\n", redirectPath) + } + + // Verify the redirect target exists + if info, err := os.Stat(redirectPath); err == nil && info.IsDir() { + if os.Getenv("BD_DEBUG_ROUTING") != "" { + fmt.Fprintf(os.Stderr, "[routing] Followed redirect from %s -> %s\n", beadsDir, redirectPath) + } + return redirectPath + } else if os.Getenv("BD_DEBUG_ROUTING") != "" { + fmt.Fprintf(os.Stderr, "[routing] Redirect target check failed: %v\n", err) + } + + return beadsDir +} + +// RoutedStorage represents a storage connection that may have been routed +// to a different beads directory than the local one. +type RoutedStorage struct { + Storage storage.Storage + BeadsDir string + Routed bool // true if this is a routed (non-local) storage +} + +// Close closes the storage connection +func (rs *RoutedStorage) Close() error { + if rs.Storage != nil { + return rs.Storage.Close() + } + return nil +} + +// GetRoutedStorageForID returns a storage connection for the given issue ID. +// If the ID matches a route, it opens a connection to the routed database. +// Otherwise, it returns nil (caller should use their existing storage). +// +// The caller is responsible for closing the returned RoutedStorage. +func GetRoutedStorageForID(ctx context.Context, id, currentBeadsDir string) (*RoutedStorage, error) { + beadsDir, routed, err := ResolveBeadsDirForID(ctx, id, currentBeadsDir) + if err != nil { + return nil, err + } + + if !routed { + return nil, nil // No routing needed, caller should use existing storage + } + + // Open storage for the routed directory + dbPath := filepath.Join(beadsDir, "beads.db") + store, err := sqlite.New(ctx, dbPath) + if err != nil { + return nil, err + } + + return &RoutedStorage{ + Storage: store, + BeadsDir: beadsDir, + Routed: true, + }, nil +}