fix(orphans): honor --db flag for cross-repo orphan detection (#1200)
* fix(orphans): honor --db flag for cross-repo orphan detection Problem: - `bd orphans --db /path` ignored the --db flag entirely - FindOrphanedIssues() hardcoded local .beads/ directory Solution: - Introduce IssueProvider interface for abstract issue lookup - Add StorageProvider adapter wrapping Storage instances - Update FindOrphanedIssues to accept provider instead of path - Wire orphans command to create provider from --db flag Closes: steveyegge/beads#1196 * test(orphans): add cross-repo and provider tests for --db flag fix - Add TestFindOrphanedIssues_WithMockProvider (table-driven, UT-01 through UT-09) - Add TestFindOrphanedIssues_CrossRepo (validates --db flag honored) - Add TestFindOrphanedIssues_LocalProvider (backward compat RT-01) - Add TestFindOrphanedIssues_ProviderError (error handling UT-07) - Add TestFindOrphanedIssues_IntegrationCrossRepo (IT-02 full) - Add TestLocalProvider_* unit tests Coverage for IssueProvider interface and cross-repo orphan detection. * docs: add bd orphans command to CLI reference Document the orphan detection command including the cross-repo workflow enabled by the --db flag fix in this PR.
This commit is contained in:
committed by
GitHub
parent
a0dac11e42
commit
c11fa799be
@@ -1,6 +1,7 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/steveyegge/beads/cmd/bd/doctor/fix"
|
||||
"github.com/steveyegge/beads/internal/git"
|
||||
"github.com/steveyegge/beads/internal/syncbranch"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -774,64 +776,35 @@ func FixSyncBranchHealth(path string) error {
|
||||
|
||||
// FindOrphanedIssues identifies issues referenced in git commits but still open in the database.
|
||||
// This is the shared core logic used by both 'bd orphans' and 'bd doctor' commands.
|
||||
// Returns empty slice if not a git repo, no database, or no orphans found (no error).
|
||||
func FindOrphanedIssues(path string) ([]OrphanIssue, error) {
|
||||
// Returns empty slice if not a git repo, no issues from provider, or no orphans found (no error).
|
||||
//
|
||||
// Parameters:
|
||||
// - gitPath: The directory to scan for git commits
|
||||
// - provider: The issue provider to get open issues and prefix from
|
||||
func FindOrphanedIssues(gitPath string, provider types.IssueProvider) ([]OrphanIssue, error) {
|
||||
// Skip if not in a git repo
|
||||
cmd := exec.Command("git", "rev-parse", "--git-dir")
|
||||
cmd.Dir = path
|
||||
cmd.Dir = gitPath
|
||||
if err := cmd.Run(); err != nil {
|
||||
return []OrphanIssue{}, nil // Not a git repo, return empty list
|
||||
}
|
||||
|
||||
// Follow redirect to resolve actual beads directory (bd-tvus fix)
|
||||
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
|
||||
// Get issue prefix from provider
|
||||
issuePrefix := provider.GetIssuePrefix()
|
||||
|
||||
// Skip if no .beads directory
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
return []OrphanIssue{}, nil
|
||||
}
|
||||
|
||||
// Get database path
|
||||
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return []OrphanIssue{}, nil
|
||||
}
|
||||
|
||||
// Open database read-only
|
||||
db, err := openDBReadOnly(dbPath)
|
||||
// Get all open/in_progress issues from provider
|
||||
ctx := context.Background()
|
||||
issues, err := provider.GetOpenIssues(ctx)
|
||||
if err != nil {
|
||||
return []OrphanIssue{}, nil
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Get issue prefix from config
|
||||
var issuePrefix string
|
||||
err = db.QueryRow("SELECT value FROM config WHERE key = 'issue_prefix'").Scan(&issuePrefix)
|
||||
if err != nil || issuePrefix == "" {
|
||||
issuePrefix = "bd" // default
|
||||
}
|
||||
|
||||
// Get all open/in_progress issues with their titles (title is optional for compatibility)
|
||||
var rows *sql.Rows
|
||||
rows, err = db.Query("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 = db.Query("SELECT id, '', status FROM issues WHERE status IN ('open', 'in_progress')")
|
||||
if err != nil {
|
||||
return []OrphanIssue{}, nil
|
||||
}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
openIssues := make(map[string]*OrphanIssue)
|
||||
for rows.Next() {
|
||||
var id, title, status string
|
||||
if err := rows.Scan(&id, &title, &status); err == nil {
|
||||
openIssues[id] = &OrphanIssue{
|
||||
IssueID: id,
|
||||
Title: title,
|
||||
Status: status,
|
||||
}
|
||||
for _, issue := range issues {
|
||||
openIssues[issue.ID] = &OrphanIssue{
|
||||
IssueID: issue.ID,
|
||||
Title: issue.Title,
|
||||
Status: string(issue.Status),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -841,7 +814,7 @@ func FindOrphanedIssues(path string) ([]OrphanIssue, error) {
|
||||
|
||||
// Get git log
|
||||
cmd = exec.Command("git", "log", "--oneline", "--all")
|
||||
cmd.Dir = path
|
||||
cmd.Dir = gitPath
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return []OrphanIssue{}, nil
|
||||
@@ -896,6 +869,103 @@ func FindOrphanedIssues(path string) ([]OrphanIssue, error) {
|
||||
return orphanedIssues, nil
|
||||
}
|
||||
|
||||
// FindOrphanedIssuesFromPath is a convenience function for callers that don't have a provider.
|
||||
// It creates a local provider from the given path's .beads/ directory.
|
||||
// This preserves backward compatibility for CheckOrphanedIssues and similar callers.
|
||||
func FindOrphanedIssuesFromPath(path string) ([]OrphanIssue, error) {
|
||||
// Follow redirect to resolve actual beads directory (bd-tvus fix)
|
||||
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
|
||||
|
||||
// Skip if no .beads directory
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
return []OrphanIssue{}, nil
|
||||
}
|
||||
|
||||
// Get database path
|
||||
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return []OrphanIssue{}, nil
|
||||
}
|
||||
|
||||
// Create a local provider from the database
|
||||
provider, err := NewLocalProvider(dbPath)
|
||||
if err != nil {
|
||||
return []OrphanIssue{}, nil
|
||||
}
|
||||
defer provider.Close()
|
||||
|
||||
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".
|
||||
@@ -936,8 +1006,8 @@ func CheckOrphanedIssues(path string) DoctorCheck {
|
||||
}
|
||||
}
|
||||
|
||||
// Use the shared FindOrphanedIssues function
|
||||
orphans, err := FindOrphanedIssues(path)
|
||||
// Use the shared FindOrphanedIssuesFromPath function (creates its own provider)
|
||||
orphans, err := FindOrphanedIssuesFromPath(path)
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Orphaned Issues",
|
||||
|
||||
Reference in New Issue
Block a user