Fixes 15 pre-existing lint issues: errcheck (6 issues): - mol_distill.go: Add _ = for f.Close() and os.Remove() - routed.go: Add _ = for routedStorage.Close() (4 locations) gosec (8 issues): - maintenance.go, routes.go: Add nolint for G304 (file paths from known dirs) - mol_distill.go: Add nolint for G304 (file creation in known search paths) - formula.go: Change WriteFile permissions from 0644 to 0600 (G306) - gate.go: Add nolint for G204 (exec.Command with trusted AwaitID fields) misspell (1 issue): - gate.go: Fix "cancelled" -> "canceled" in comment unparam (2 issues): - cook.go, controlflow.go: Add nolint for functions returning always-nil error Also: - Update pre-commit-hooks to v6.0.0 - Add lint step to "Landing the Plane" session-end protocol
203 lines
6.0 KiB
Go
203 lines
6.0 KiB
Go
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) //nolint:gosec // routesPath is constructed from known beadsDir
|
|
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) //nolint:gosec // redirectFile is constructed from known beadsDir
|
|
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
|
|
}
|