diff --git a/cmd/bd/doctor/git.go b/cmd/bd/doctor/git.go index e0f1a216..e86c0f1b 100644 --- a/cmd/bd/doctor/git.go +++ b/cmd/bd/doctor/git.go @@ -14,6 +14,7 @@ import ( _ "github.com/ncruces/go-sqlite3/embed" "github.com/steveyegge/beads/cmd/bd/doctor/fix" "github.com/steveyegge/beads/internal/git" + "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/syncbranch" "github.com/steveyegge/beads/internal/types" ) @@ -888,7 +889,7 @@ func FindOrphanedIssuesFromPath(path string) ([]OrphanIssue, error) { } // Create a local provider from the database - provider, err := NewLocalProvider(dbPath) + provider, err := storage.NewLocalProvider(dbPath) if err != nil { return []OrphanIssue{}, nil } @@ -897,75 +898,6 @@ func FindOrphanedIssuesFromPath(path string) ([]OrphanIssue, error) { return FindOrphanedIssues(path, provider) } -// LocalProvider implements types.IssueProvider using a read-only SQLite connection. -// This is used by CheckOrphanedIssues and other internal callers that need a provider -// from a local .beads/ directory. -type LocalProvider struct { - db *sql.DB - prefix string -} - -// NewLocalProvider creates a provider backed by a SQLite database. -func NewLocalProvider(dbPath string) (*LocalProvider, error) { - db, err := openDBReadOnly(dbPath) - if err != nil { - return nil, err - } - - // Get issue prefix from config - var prefix string - err = db.QueryRow("SELECT value FROM config WHERE key = 'issue_prefix'").Scan(&prefix) - if err != nil || prefix == "" { - prefix = "bd" // default - } - - return &LocalProvider{db: db, prefix: prefix}, nil -} - -// GetOpenIssues returns issues that are open or in_progress. -func (p *LocalProvider) GetOpenIssues(ctx context.Context) ([]*types.Issue, error) { - // Get all open/in_progress issues with their titles (title is optional for compatibility) - rows, err := p.db.QueryContext(ctx, "SELECT id, title, status FROM issues WHERE status IN ('open', 'in_progress')") - // If the query fails (e.g., no title column), fall back to simpler query - if err != nil { - rows, err = p.db.QueryContext(ctx, "SELECT id, '', status FROM issues WHERE status IN ('open', 'in_progress')") - if err != nil { - return nil, err - } - } - defer rows.Close() - - var issues []*types.Issue - for rows.Next() { - var id, title, status string - if err := rows.Scan(&id, &title, &status); err == nil { - issues = append(issues, &types.Issue{ - ID: id, - Title: title, - Status: types.Status(status), - }) - } - } - - return issues, nil -} - -// GetIssuePrefix returns the configured issue prefix. -func (p *LocalProvider) GetIssuePrefix() string { - return p.prefix -} - -// Close closes the underlying database connection. -func (p *LocalProvider) Close() error { - if p.db != nil { - return p.db.Close() - } - return nil -} - -// Ensure LocalProvider implements types.IssueProvider -var _ types.IssueProvider = (*LocalProvider)(nil) - // CheckOrphanedIssues detects issues referenced in git commits but still open. // This catches cases where someone implemented a fix with "(bd-xxx)" in the commit // message but forgot to run "bd close". diff --git a/cmd/bd/doctor/orphans_provider_test.go b/cmd/bd/doctor/orphans_provider_test.go index 2a0fdcda..31bbf68d 100644 --- a/cmd/bd/doctor/orphans_provider_test.go +++ b/cmd/bd/doctor/orphans_provider_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "testing" + "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/types" _ "github.com/ncruces/go-sqlite3/driver" @@ -443,7 +444,7 @@ func TestFindOrphanedIssues_IntegrationCrossRepo(t *testing.T) { _ = cmd.Run() // Create a real LocalProvider from the planning database - provider, err := NewLocalProvider(planningDBPath) + provider, err := storage.NewLocalProvider(planningDBPath) if err != nil { t.Fatalf("failed to create LocalProvider: %v", err) } @@ -490,9 +491,9 @@ func TestLocalProvider_Methods(t *testing.T) { db.Close() // Create provider - provider, err := NewLocalProvider(dbPath) + provider, err := storage.NewLocalProvider(dbPath) if err != nil { - t.Fatalf("NewLocalProvider failed: %v", err) + t.Fatalf("storage.NewLocalProvider failed: %v", err) } defer provider.Close() @@ -550,9 +551,9 @@ func TestLocalProvider_DefaultPrefix(t *testing.T) { } db.Close() - provider, err := NewLocalProvider(dbPath) + provider, err := storage.NewLocalProvider(dbPath) if err != nil { - t.Fatalf("NewLocalProvider failed: %v", err) + t.Fatalf("storage.NewLocalProvider failed: %v", err) } defer provider.Close() diff --git a/cmd/bd/orphans.go b/cmd/bd/orphans.go index f818fee1..bce8d9ce 100644 --- a/cmd/bd/orphans.go +++ b/cmd/bd/orphans.go @@ -113,7 +113,7 @@ type orphanIssueOutput struct { func getIssueProvider() (types.IssueProvider, func(), error) { // If --db flag is set and we have a dbPath, create a provider from that path if dbPath != "" { - provider, err := doctor.NewLocalProvider(dbPath) + provider, err := storage.NewLocalProvider(dbPath) if err != nil { return nil, nil, fmt.Errorf("failed to open database at %s: %w", dbPath, err) } diff --git a/internal/storage/local_provider.go b/internal/storage/local_provider.go new file mode 100644 index 00000000..447da790 --- /dev/null +++ b/internal/storage/local_provider.go @@ -0,0 +1,106 @@ +// Package storage defines the interface for issue storage backends. +package storage + +import ( + "context" + "database/sql" + "fmt" + "os" + "strings" + "time" + + "github.com/steveyegge/beads/internal/types" +) + +// LocalProvider implements types.IssueProvider using a read-only SQLite connection. +// This is used for cross-repo orphan detection when --db flag points to an external database. +type LocalProvider struct { + db *sql.DB + prefix string +} + +// NewLocalProvider creates a provider backed by a SQLite database at the given path. +func NewLocalProvider(dbPath string) (*LocalProvider, error) { + db, err := openDBReadOnly(dbPath) + if err != nil { + return nil, err + } + + // Get issue prefix from config + var prefix string + err = db.QueryRow("SELECT value FROM config WHERE key = 'issue_prefix'").Scan(&prefix) + if err != nil || prefix == "" { + prefix = "bd" // default + } + + return &LocalProvider{db: db, prefix: prefix}, nil +} + +// GetOpenIssues returns issues that are open or in_progress. +func (p *LocalProvider) GetOpenIssues(ctx context.Context) ([]*types.Issue, error) { + // Get all open/in_progress issues with their titles (title is optional for compatibility) + rows, err := p.db.QueryContext(ctx, "SELECT id, title, status FROM issues WHERE status IN ('open', 'in_progress')") + // If the query fails (e.g., no title column), fall back to simpler query + if err != nil { + rows, err = p.db.QueryContext(ctx, "SELECT id, '', status FROM issues WHERE status IN ('open', 'in_progress')") + if err != nil { + return nil, err + } + } + defer rows.Close() + + var issues []*types.Issue + for rows.Next() { + var id, title, status string + if err := rows.Scan(&id, &title, &status); err == nil { + issues = append(issues, &types.Issue{ + ID: id, + Title: title, + Status: types.Status(status), + }) + } + } + + return issues, nil +} + +// GetIssuePrefix returns the configured issue prefix. +func (p *LocalProvider) GetIssuePrefix() string { + return p.prefix +} + +// Close closes the underlying database connection. +func (p *LocalProvider) Close() error { + if p.db != nil { + return p.db.Close() + } + return nil +} + +// Ensure LocalProvider implements types.IssueProvider +var _ types.IssueProvider = (*LocalProvider)(nil) + +// openDBReadOnly opens a SQLite database in read-only mode. +func openDBReadOnly(dbPath string) (*sql.DB, error) { + connStr := sqliteReadOnlyConnString(dbPath) + return sql.Open("sqlite3", connStr) +} + +// sqliteReadOnlyConnString builds a read-only SQLite connection string. +func sqliteReadOnlyConnString(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + + // Honor BD_LOCK_TIMEOUT env var for busy timeout + busy := 30 * time.Second + if v := strings.TrimSpace(os.Getenv("BD_LOCK_TIMEOUT")); v != "" { + if d, err := time.ParseDuration(v); err == nil { + busy = d + } + } + busyMs := int64(busy / time.Millisecond) + + return fmt.Sprintf("file:%s?mode=ro&_pragma=foreign_keys(ON)&_pragma=busy_timeout(%d)", path, busyMs) +}