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:
166
cmd/bd/routed.go
Normal file
166
cmd/bd/routed.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
var resolvedIDs []string
|
||||||
if daemonClient != nil {
|
if daemonClient != nil {
|
||||||
// In daemon mode, resolve via RPC
|
// In daemon mode, resolve via RPC
|
||||||
@@ -53,19 +53,25 @@ var showCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
resolvedIDs = append(resolvedIDs, resolvedID)
|
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
|
// Handle --thread flag: show full conversation thread
|
||||||
if showThread && len(resolvedIDs) > 0 {
|
if showThread {
|
||||||
showMessageThread(ctx, resolvedIDs[0], jsonOutput)
|
if daemonClient != nil && len(resolvedIDs) > 0 {
|
||||||
return
|
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
|
// If daemon is running, use RPC
|
||||||
@@ -246,18 +252,24 @@ var showCmd = &cobra.Command{
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct mode
|
// Direct mode - use routed resolution for cross-repo lookups
|
||||||
allDetails := []interface{}{}
|
allDetails := []interface{}{}
|
||||||
for idx, id := range resolvedIDs {
|
for idx, id := range args {
|
||||||
issue, err := store.GetIssue(ctx, id)
|
// 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 {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", id, err)
|
fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", id, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if issue == nil {
|
if result == nil || result.Issue == nil {
|
||||||
fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
|
fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
issue := result.Issue
|
||||||
|
issueStore := result.Store // Use the store that contains this issue
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
// Include labels, dependencies (with metadata), dependents (with metadata), and comments in JSON output
|
// 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"`
|
Comments []*types.Comment `json:"comments,omitempty"`
|
||||||
}
|
}
|
||||||
details := &IssueDetails{Issue: issue}
|
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)
|
// 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.Dependencies, _ = sqliteStore.GetDependenciesWithMetadata(ctx, issue.ID)
|
||||||
details.Dependents, _ = sqliteStore.GetDependentsWithMetadata(ctx, issue.ID)
|
details.Dependents, _ = sqliteStore.GetDependentsWithMetadata(ctx, issue.ID)
|
||||||
} else {
|
} else {
|
||||||
// Fallback to regular methods without metadata for other storage backends
|
// 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 {
|
for _, dep := range deps {
|
||||||
details.Dependencies = append(details.Dependencies, &types.IssueWithDependencyMetadata{Issue: *dep})
|
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 {
|
for _, dependent := range dependents {
|
||||||
details.Dependents = append(details.Dependents, &types.IssueWithDependencyMetadata{Issue: *dependent})
|
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)
|
allDetails = append(allDetails, details)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -366,13 +378,13 @@ var showCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show labels
|
// Show labels
|
||||||
labels, _ := store.GetLabels(ctx, issue.ID)
|
labels, _ := issueStore.GetLabels(ctx, issue.ID)
|
||||||
if len(labels) > 0 {
|
if len(labels) > 0 {
|
||||||
fmt.Printf("\nLabels: %v\n", labels)
|
fmt.Printf("\nLabels: %v\n", labels)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show dependencies
|
// Show dependencies
|
||||||
deps, _ := store.GetDependencies(ctx, issue.ID)
|
deps, _ := issueStore.GetDependencies(ctx, issue.ID)
|
||||||
if len(deps) > 0 {
|
if len(deps) > 0 {
|
||||||
fmt.Printf("\nDepends on (%d):\n", len(deps))
|
fmt.Printf("\nDepends on (%d):\n", len(deps))
|
||||||
for _, dep := range deps {
|
for _, dep := range deps {
|
||||||
@@ -382,7 +394,7 @@ var showCmd = &cobra.Command{
|
|||||||
|
|
||||||
// Show dependents - grouped by dependency type for clarity
|
// Show dependents - grouped by dependency type for clarity
|
||||||
// Use GetDependentsWithMetadata to get the dependency type
|
// Use GetDependentsWithMetadata to get the dependency type
|
||||||
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
|
sqliteStore, ok := issueStore.(*sqlite.SQLiteStorage)
|
||||||
if ok {
|
if ok {
|
||||||
dependentsWithMeta, _ := sqliteStore.GetDependentsWithMetadata(ctx, issue.ID)
|
dependentsWithMeta, _ := sqliteStore.GetDependentsWithMetadata(ctx, issue.ID)
|
||||||
if len(dependentsWithMeta) > 0 {
|
if len(dependentsWithMeta) > 0 {
|
||||||
@@ -430,7 +442,7 @@ var showCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback for non-SQLite storage
|
// Fallback for non-SQLite storage
|
||||||
dependents, _ := store.GetDependents(ctx, issue.ID)
|
dependents, _ := issueStore.GetDependents(ctx, issue.ID)
|
||||||
if len(dependents) > 0 {
|
if len(dependents) > 0 {
|
||||||
fmt.Printf("\nBlocks (%d):\n", len(dependents))
|
fmt.Printf("\nBlocks (%d):\n", len(dependents))
|
||||||
for _, dep := range dependents {
|
for _, dep := range dependents {
|
||||||
@@ -440,7 +452,7 @@ var showCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show comments
|
// Show comments
|
||||||
comments, _ := store.GetIssueComments(ctx, issue.ID)
|
comments, _ := issueStore.GetIssueComments(ctx, issue.ID)
|
||||||
if len(comments) > 0 {
|
if len(comments) > 0 {
|
||||||
fmt.Printf("\nComments (%d):\n", len(comments))
|
fmt.Printf("\nComments (%d):\n", len(comments))
|
||||||
for _, comment := range comments {
|
for _, comment := range comments {
|
||||||
|
|||||||
202
internal/routing/routes.go
Normal file
202
internal/routing/routes.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user