refactor(storage): move LocalProvider to internal/storage package
Move LocalProvider from cmd/bd/doctor/git.go to internal/storage/local_provider.go where it belongs alongside StorageProvider. Both implement IssueProvider for orphan detection - LocalProvider for direct SQLite access (--db flag), StorageProvider for the global Storage interface. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
8da7f274d6
commit
2a56aab92c
@@ -14,6 +14,7 @@ import (
|
|||||||
_ "github.com/ncruces/go-sqlite3/embed"
|
_ "github.com/ncruces/go-sqlite3/embed"
|
||||||
"github.com/steveyegge/beads/cmd/bd/doctor/fix"
|
"github.com/steveyegge/beads/cmd/bd/doctor/fix"
|
||||||
"github.com/steveyegge/beads/internal/git"
|
"github.com/steveyegge/beads/internal/git"
|
||||||
|
"github.com/steveyegge/beads/internal/storage"
|
||||||
"github.com/steveyegge/beads/internal/syncbranch"
|
"github.com/steveyegge/beads/internal/syncbranch"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
)
|
)
|
||||||
@@ -888,7 +889,7 @@ func FindOrphanedIssuesFromPath(path string) ([]OrphanIssue, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a local provider from the database
|
// Create a local provider from the database
|
||||||
provider, err := NewLocalProvider(dbPath)
|
provider, err := storage.NewLocalProvider(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return []OrphanIssue{}, nil
|
return []OrphanIssue{}, nil
|
||||||
}
|
}
|
||||||
@@ -897,75 +898,6 @@ func FindOrphanedIssuesFromPath(path string) ([]OrphanIssue, error) {
|
|||||||
return FindOrphanedIssues(path, provider)
|
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.
|
// CheckOrphanedIssues detects issues referenced in git commits but still open.
|
||||||
// This catches cases where someone implemented a fix with "(bd-xxx)" in the commit
|
// This catches cases where someone implemented a fix with "(bd-xxx)" in the commit
|
||||||
// message but forgot to run "bd close".
|
// message but forgot to run "bd close".
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/storage"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
|
|
||||||
_ "github.com/ncruces/go-sqlite3/driver"
|
_ "github.com/ncruces/go-sqlite3/driver"
|
||||||
@@ -443,7 +444,7 @@ func TestFindOrphanedIssues_IntegrationCrossRepo(t *testing.T) {
|
|||||||
_ = cmd.Run()
|
_ = cmd.Run()
|
||||||
|
|
||||||
// Create a real LocalProvider from the planning database
|
// Create a real LocalProvider from the planning database
|
||||||
provider, err := NewLocalProvider(planningDBPath)
|
provider, err := storage.NewLocalProvider(planningDBPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create LocalProvider: %v", err)
|
t.Fatalf("failed to create LocalProvider: %v", err)
|
||||||
}
|
}
|
||||||
@@ -490,9 +491,9 @@ func TestLocalProvider_Methods(t *testing.T) {
|
|||||||
db.Close()
|
db.Close()
|
||||||
|
|
||||||
// Create provider
|
// Create provider
|
||||||
provider, err := NewLocalProvider(dbPath)
|
provider, err := storage.NewLocalProvider(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewLocalProvider failed: %v", err)
|
t.Fatalf("storage.NewLocalProvider failed: %v", err)
|
||||||
}
|
}
|
||||||
defer provider.Close()
|
defer provider.Close()
|
||||||
|
|
||||||
@@ -550,9 +551,9 @@ func TestLocalProvider_DefaultPrefix(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Close()
|
db.Close()
|
||||||
|
|
||||||
provider, err := NewLocalProvider(dbPath)
|
provider, err := storage.NewLocalProvider(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewLocalProvider failed: %v", err)
|
t.Fatalf("storage.NewLocalProvider failed: %v", err)
|
||||||
}
|
}
|
||||||
defer provider.Close()
|
defer provider.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ type orphanIssueOutput struct {
|
|||||||
func getIssueProvider() (types.IssueProvider, func(), error) {
|
func getIssueProvider() (types.IssueProvider, func(), error) {
|
||||||
// If --db flag is set and we have a dbPath, create a provider from that path
|
// If --db flag is set and we have a dbPath, create a provider from that path
|
||||||
if dbPath != "" {
|
if dbPath != "" {
|
||||||
provider, err := doctor.NewLocalProvider(dbPath)
|
provider, err := storage.NewLocalProvider(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to open database at %s: %w", dbPath, err)
|
return nil, nil, fmt.Errorf("failed to open database at %s: %w", dbPath, err)
|
||||||
}
|
}
|
||||||
|
|||||||
106
internal/storage/local_provider.go
Normal file
106
internal/storage/local_provider.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user