feat: add bd doctor check for orphaned issues (bd-5hrq)

- Add CheckOrphanedIssues to detect issues referenced in commits but still open
- Pattern matches (prefix-xxx) in git log against open issues in database
- Reports warning with issue IDs and commit hashes
- Add 8 comprehensive tests for the new check

Also:
- Add tests for mol spawn --attach functionality (bd-f7p1)
- Document commit message convention in AGENT_INSTRUCTIONS.md
- Fix CheckpointWAL to use wrapDBError for consistency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-21 22:04:59 -08:00
parent 61d70cd1c3
commit 240a4e2dbc
6 changed files with 1019 additions and 1 deletions

View File

@@ -1,11 +1,15 @@
package doctor
import (
"database/sql"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
)
// setupGitRepo creates a temporary git repository for testing
@@ -800,3 +804,298 @@ func TestCheckSyncBranchHookCompatibility_OldHookFormat(t *testing.T) {
})
}
}
// Tests for CheckOrphanedIssues
func TestCheckOrphanedIssues_NoGitRepo(t *testing.T) {
tmpDir := t.TempDir()
// No git init - just a plain directory
check := CheckOrphanedIssues(tmpDir)
if check.Status != StatusOK {
t.Errorf("expected status %q, got %q", StatusOK, check.Status)
}
if !strings.Contains(check.Message, "not a git repository") {
t.Errorf("expected message about not a git repository, got %q", check.Message)
}
}
func TestCheckOrphanedIssues_NoBeadsDir(t *testing.T) {
tmpDir := t.TempDir()
// Initialize git repo WITHOUT creating .beads
cmd := exec.Command("git", "init")
cmd.Dir = tmpDir
if err := cmd.Run(); err != nil {
t.Fatal(err)
}
check := CheckOrphanedIssues(tmpDir)
if check.Status != StatusOK {
t.Errorf("expected status %q, got %q", StatusOK, check.Status)
}
if !strings.Contains(check.Message, "no .beads directory") {
t.Errorf("expected message about no .beads directory, got %q", check.Message)
}
}
func TestCheckOrphanedIssues_NoDatabase(t *testing.T) {
tmpDir := t.TempDir()
setupGitRepoInDir(t, tmpDir)
// Create .beads directory but no database
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
check := CheckOrphanedIssues(tmpDir)
if check.Status != StatusOK {
t.Errorf("expected status %q, got %q", StatusOK, check.Status)
}
if !strings.Contains(check.Message, "no database") {
t.Errorf("expected message about no database, got %q", check.Message)
}
}
func TestCheckOrphanedIssues_NoOpenIssues(t *testing.T) {
tmpDir := t.TempDir()
setupGitRepoInDir(t, tmpDir)
// Create .beads directory and database
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
// Create a minimal SQLite database with schema
dbPath := filepath.Join(beadsDir, "beads.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Create tables
_, err = db.Exec(`
CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE issues (id TEXT PRIMARY KEY, status TEXT);
INSERT INTO config (key, value) VALUES ('issue_prefix', 'bd');
`)
if err != nil {
t.Fatal(err)
}
check := CheckOrphanedIssues(tmpDir)
if check.Status != StatusOK {
t.Errorf("expected status %q, got %q", StatusOK, check.Status)
}
if !strings.Contains(check.Message, "No open issues") {
t.Errorf("expected message about no open issues, got %q", check.Message)
}
}
func TestCheckOrphanedIssues_OpenIssueNotInCommits(t *testing.T) {
tmpDir := t.TempDir()
setupGitRepoInDir(t, tmpDir)
// Create .beads directory and database
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
// Create database with an open issue
dbPath := filepath.Join(beadsDir, "beads.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`
CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE issues (id TEXT PRIMARY KEY, status TEXT);
INSERT INTO config (key, value) VALUES ('issue_prefix', 'bd');
INSERT INTO issues (id, status) VALUES ('bd-abc', 'open');
`)
if err != nil {
t.Fatal(err)
}
db.Close()
// Create a commit without the issue reference
readme := filepath.Join(tmpDir, "README.md")
if err := os.WriteFile(readme, []byte("# Test"), 0644); err != nil {
t.Fatal(err)
}
cmd := exec.Command("git", "add", "README.md")
cmd.Dir = tmpDir
_ = cmd.Run()
cmd = exec.Command("git", "commit", "-m", "Initial commit")
cmd.Dir = tmpDir
_ = cmd.Run()
check := CheckOrphanedIssues(tmpDir)
if check.Status != StatusOK {
t.Errorf("expected status %q, got %q", StatusOK, check.Status)
}
if !strings.Contains(check.Message, "No issues referenced") {
t.Errorf("expected message about no issues referenced, got %q", check.Message)
}
}
func TestCheckOrphanedIssues_OpenIssueInCommit(t *testing.T) {
tmpDir := t.TempDir()
setupGitRepoInDir(t, tmpDir)
// Create .beads directory and database
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
// Create database with an open issue
dbPath := filepath.Join(beadsDir, "beads.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`
CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE issues (id TEXT PRIMARY KEY, status TEXT);
INSERT INTO config (key, value) VALUES ('issue_prefix', 'bd');
INSERT INTO issues (id, status) VALUES ('bd-abc', 'open');
`)
if err != nil {
t.Fatal(err)
}
db.Close()
// Create a commit WITH the issue reference
readme := filepath.Join(tmpDir, "README.md")
if err := os.WriteFile(readme, []byte("# Test"), 0644); err != nil {
t.Fatal(err)
}
cmd := exec.Command("git", "add", "README.md")
cmd.Dir = tmpDir
_ = cmd.Run()
cmd = exec.Command("git", "commit", "-m", "Fix bug (bd-abc)")
cmd.Dir = tmpDir
_ = cmd.Run()
check := CheckOrphanedIssues(tmpDir)
if check.Status != StatusWarning {
t.Errorf("expected status %q, got %q (message: %s)", StatusWarning, check.Status, check.Message)
}
if !strings.Contains(check.Message, "1 issue(s) referenced") {
t.Errorf("expected message about 1 issue referenced, got %q", check.Message)
}
if !strings.Contains(check.Detail, "bd-abc") {
t.Errorf("expected detail to contain bd-abc, got %q", check.Detail)
}
}
func TestCheckOrphanedIssues_ClosedIssueInCommit(t *testing.T) {
tmpDir := t.TempDir()
setupGitRepoInDir(t, tmpDir)
// Create .beads directory and database
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
// Create database with a CLOSED issue
dbPath := filepath.Join(beadsDir, "beads.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`
CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE issues (id TEXT PRIMARY KEY, status TEXT);
INSERT INTO config (key, value) VALUES ('issue_prefix', 'bd');
INSERT INTO issues (id, status) VALUES ('bd-abc', 'closed');
`)
if err != nil {
t.Fatal(err)
}
db.Close()
// Create a commit with the issue reference
readme := filepath.Join(tmpDir, "README.md")
if err := os.WriteFile(readme, []byte("# Test"), 0644); err != nil {
t.Fatal(err)
}
cmd := exec.Command("git", "add", "README.md")
cmd.Dir = tmpDir
_ = cmd.Run()
cmd = exec.Command("git", "commit", "-m", "Fix bug (bd-abc)")
cmd.Dir = tmpDir
_ = cmd.Run()
check := CheckOrphanedIssues(tmpDir)
// Should be OK because the issue is closed
if check.Status != StatusOK {
t.Errorf("expected status %q, got %q (message: %s)", StatusOK, check.Status, check.Message)
}
}
func TestCheckOrphanedIssues_HierarchicalIssueID(t *testing.T) {
tmpDir := t.TempDir()
setupGitRepoInDir(t, tmpDir)
// Create .beads directory and database
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
// Create database with a hierarchical issue ID
dbPath := filepath.Join(beadsDir, "beads.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`
CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE issues (id TEXT PRIMARY KEY, status TEXT);
INSERT INTO config (key, value) VALUES ('issue_prefix', 'bd');
INSERT INTO issues (id, status) VALUES ('bd-abc.1', 'open');
`)
if err != nil {
t.Fatal(err)
}
db.Close()
// Create a commit with the hierarchical issue reference
readme := filepath.Join(tmpDir, "README.md")
if err := os.WriteFile(readme, []byte("# Test"), 0644); err != nil {
t.Fatal(err)
}
cmd := exec.Command("git", "add", "README.md")
cmd.Dir = tmpDir
_ = cmd.Run()
cmd = exec.Command("git", "commit", "-m", "Fix subtask (bd-abc.1)")
cmd.Dir = tmpDir
_ = cmd.Run()
check := CheckOrphanedIssues(tmpDir)
if check.Status != StatusWarning {
t.Errorf("expected status %q, got %q (message: %s)", StatusWarning, check.Status, check.Message)
}
if !strings.Contains(check.Detail, "bd-abc.1") {
t.Errorf("expected detail to contain bd-abc.1, got %q", check.Detail)
}
}