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:
@@ -61,6 +61,17 @@ func TestMyFeature(t *testing.T) {
|
|||||||
3. **Update docs**: If you changed behavior, update README.md or other docs
|
3. **Update docs**: If you changed behavior, update README.md or other docs
|
||||||
4. **Commit**: Issues auto-sync to `.beads/issues.jsonl` and import after pull
|
4. **Commit**: Issues auto-sync to `.beads/issues.jsonl` and import after pull
|
||||||
|
|
||||||
|
### Commit Message Convention
|
||||||
|
|
||||||
|
When committing work for an issue, include the issue ID in parentheses at the end:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "Fix auth validation bug (bd-abc)"
|
||||||
|
git commit -m "Add retry logic for database locks (bd-xyz)"
|
||||||
|
```
|
||||||
|
|
||||||
|
This enables `bd doctor` to detect **orphaned issues** - work that was committed but the issue wasn't closed. The doctor check cross-references open issues against git history to find these orphans.
|
||||||
|
|
||||||
### Git Workflow
|
### Git Workflow
|
||||||
|
|
||||||
**Auto-sync provides batching!** bd automatically:
|
**Auto-sync provides batching!** bd automatically:
|
||||||
|
|||||||
@@ -719,6 +719,11 @@ func runDiagnostics(path string) doctorResult {
|
|||||||
result.Checks = append(result.Checks, syncBranchHealthCheck)
|
result.Checks = append(result.Checks, syncBranchHealthCheck)
|
||||||
// Don't fail overall check for sync branch health, just warn
|
// Don't fail overall check for sync branch health, just warn
|
||||||
|
|
||||||
|
// Check 17b: Orphaned issues - referenced in commits but still open (bd-5hrq)
|
||||||
|
orphanedIssuesCheck := convertWithCategory(doctor.CheckOrphanedIssues(path), doctor.CategoryGit)
|
||||||
|
result.Checks = append(result.Checks, orphanedIssuesCheck)
|
||||||
|
// Don't fail overall check for orphaned issues, just warn
|
||||||
|
|
||||||
// Check 18: Deletions manifest (legacy, now replaced by tombstones)
|
// Check 18: Deletions manifest (legacy, now replaced by tombstones)
|
||||||
deletionsCheck := convertWithCategory(doctor.CheckDeletionsManifest(path), doctor.CategoryMetadata)
|
deletionsCheck := convertWithCategory(doctor.CheckDeletionsManifest(path), doctor.CategoryMetadata)
|
||||||
result.Checks = append(result.Checks, deletionsCheck)
|
result.Checks = append(result.Checks, deletionsCheck)
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package doctor
|
package doctor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
_ "github.com/ncruces/go-sqlite3/driver"
|
||||||
|
_ "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/syncbranch"
|
"github.com/steveyegge/beads/internal/syncbranch"
|
||||||
@@ -501,3 +505,162 @@ func FixMergeDriver(path string) error {
|
|||||||
func FixSyncBranchHealth(path string) error {
|
func FixSyncBranchHealth(path string) error {
|
||||||
return fix.DBJSONLSync(path)
|
return fix.DBJSONLSync(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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".
|
||||||
|
func CheckOrphanedIssues(path string) DoctorCheck {
|
||||||
|
// Skip if not in a git repo (check from path directory)
|
||||||
|
cmd := exec.Command("git", "rev-parse", "--git-dir")
|
||||||
|
cmd.Dir = path
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Orphaned Issues",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (not a git repository)",
|
||||||
|
Category: CategoryGit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beadsDir := filepath.Join(path, ".beads")
|
||||||
|
|
||||||
|
// Skip if no .beads directory
|
||||||
|
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Orphaned Issues",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (no .beads directory)",
|
||||||
|
Category: CategoryGit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get database path from config or use canonical name
|
||||||
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||||
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Orphaned Issues",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (no database)",
|
||||||
|
Category: CategoryGit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open database read-only
|
||||||
|
db, err := openDBReadOnly(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Orphaned Issues",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (unable to open database)",
|
||||||
|
Category: CategoryGit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 issue IDs
|
||||||
|
rows, err := db.Query("SELECT id FROM issues WHERE status IN ('open', 'in_progress')")
|
||||||
|
if err != nil {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Orphaned Issues",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (unable to query issues)",
|
||||||
|
Category: CategoryGit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
openSet := make(map[string]bool)
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err == nil {
|
||||||
|
openSet[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(openSet) == 0 {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Orphaned Issues",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "No open issues to check",
|
||||||
|
Category: CategoryGit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get issue IDs referenced in git commits
|
||||||
|
cmd = exec.Command("git", "log", "--oneline", "--all")
|
||||||
|
cmd.Dir = path
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Orphaned Issues",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (unable to read git log)",
|
||||||
|
Category: CategoryGit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse commit messages for issue references
|
||||||
|
// Match pattern like (bd-xxx) or (bd-xxx.1) including hierarchical IDs
|
||||||
|
pattern := fmt.Sprintf(`\(%s-[a-z0-9.]+\)`, regexp.QuoteMeta(issuePrefix))
|
||||||
|
re := regexp.MustCompile(pattern)
|
||||||
|
|
||||||
|
// Track which open issues appear in commits (with first commit hash)
|
||||||
|
orphanedIssues := make(map[string]string) // issue ID -> commit hash
|
||||||
|
lines := strings.Split(string(output), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches := re.FindAllString(line, -1)
|
||||||
|
for _, match := range matches {
|
||||||
|
// Extract issue ID (remove parentheses)
|
||||||
|
issueID := strings.Trim(match, "()")
|
||||||
|
if openSet[issueID] {
|
||||||
|
// Only record the first (most recent) commit
|
||||||
|
if _, exists := orphanedIssues[issueID]; !exists {
|
||||||
|
// Extract commit hash (first word of line)
|
||||||
|
parts := strings.SplitN(line, " ", 2)
|
||||||
|
if len(parts) > 0 {
|
||||||
|
orphanedIssues[issueID] = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(orphanedIssues) == 0 {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Orphaned Issues",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "No issues referenced in commits but still open",
|
||||||
|
Category: CategoryGit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build detail message
|
||||||
|
var details []string
|
||||||
|
for id, commit := range orphanedIssues {
|
||||||
|
details = append(details, fmt.Sprintf("%s (commit %s)", id, commit))
|
||||||
|
}
|
||||||
|
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Orphaned Issues",
|
||||||
|
Status: StatusWarning,
|
||||||
|
Message: fmt.Sprintf("%d issue(s) referenced in commits but still open", len(orphanedIssues)),
|
||||||
|
Detail: strings.Join(details, ", "),
|
||||||
|
Fix: "Run 'bd show <id>' to check if implemented, then 'bd close <id>' if done",
|
||||||
|
Category: CategoryGit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openDBReadOnly opens a SQLite database in read-only mode
|
||||||
|
func openDBReadOnly(dbPath string) (*sql.DB, error) {
|
||||||
|
return sql.Open("sqlite3", "file:"+dbPath+"?mode=ro")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package doctor
|
package doctor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/ncruces/go-sqlite3/driver"
|
||||||
|
_ "github.com/ncruces/go-sqlite3/embed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupGitRepo creates a temporary git repository for testing
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -742,6 +742,546 @@ func TestSquashMoleculeWithAgentSummary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Spawn --attach Tests (bd-f7p1)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestSpawnWithBasicAttach tests spawning a proto with one --attach flag
|
||||||
|
func TestSpawnWithBasicAttach(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dbPath := t.TempDir() + "/test.db"
|
||||||
|
s, err := sqlite.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to set config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create primary proto with a child
|
||||||
|
primaryProto := &types.Issue{
|
||||||
|
Title: "Primary: {{feature}}",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeEpic,
|
||||||
|
Labels: []string{MoleculeLabel},
|
||||||
|
}
|
||||||
|
if err := s.CreateIssue(ctx, primaryProto, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create primary proto: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
primaryChild := &types.Issue{
|
||||||
|
Title: "Step 1 for {{feature}}",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
}
|
||||||
|
if err := s.CreateIssue(ctx, primaryChild, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create primary child: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.AddDependency(ctx, &types.Dependency{
|
||||||
|
IssueID: primaryChild.ID,
|
||||||
|
DependsOnID: primaryProto.ID,
|
||||||
|
Type: types.DepParentChild,
|
||||||
|
}, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to add primary child dependency: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create attachment proto with a child
|
||||||
|
attachProto := &types.Issue{
|
||||||
|
Title: "Attachment: {{feature}} docs",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeEpic,
|
||||||
|
Labels: []string{MoleculeLabel},
|
||||||
|
}
|
||||||
|
if err := s.CreateIssue(ctx, attachProto, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create attach proto: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
attachChild := &types.Issue{
|
||||||
|
Title: "Write docs for {{feature}}",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 3,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
}
|
||||||
|
if err := s.CreateIssue(ctx, attachChild, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create attach child: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.AddDependency(ctx, &types.Dependency{
|
||||||
|
IssueID: attachChild.ID,
|
||||||
|
DependsOnID: attachProto.ID,
|
||||||
|
Type: types.DepParentChild,
|
||||||
|
}, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to add attach child dependency: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn primary proto
|
||||||
|
primarySubgraph, err := loadTemplateSubgraph(ctx, s, primaryProto.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load primary subgraph: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := map[string]string{"feature": "auth"}
|
||||||
|
spawnResult, err := spawnMolecule(ctx, s, primarySubgraph, vars, "", "test", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to spawn primary: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if spawnResult.Created != 2 {
|
||||||
|
t.Errorf("Spawn created = %d, want 2", spawnResult.Created)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the spawned molecule
|
||||||
|
spawnedMol, err := s.GetIssue(ctx, spawnResult.NewEpicID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get spawned molecule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach the second proto (simulating --attach flag behavior)
|
||||||
|
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, types.BondTypeSequential, vars, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to bond attachment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bondResult.Spawned != 2 {
|
||||||
|
t.Errorf("Bond spawned = %d, want 2", bondResult.Spawned)
|
||||||
|
}
|
||||||
|
if bondResult.ResultType != "compound_molecule" {
|
||||||
|
t.Errorf("ResultType = %q, want %q", bondResult.ResultType, "compound_molecule")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the spawned attachment root has dependency on the primary molecule
|
||||||
|
attachedRootID := bondResult.IDMapping[attachProto.ID]
|
||||||
|
deps, err := s.GetDependenciesWithMetadata(ctx, attachedRootID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get deps: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
foundBlocks := false
|
||||||
|
for _, dep := range deps {
|
||||||
|
if dep.ID == spawnedMol.ID && dep.DependencyType == types.DepBlocks {
|
||||||
|
foundBlocks = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundBlocks {
|
||||||
|
t.Error("Expected blocks dependency from attached proto to spawned molecule for sequential bond")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify variable substitution worked in attached issues
|
||||||
|
attachedRoot, err := s.GetIssue(ctx, attachedRootID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get attached root: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(attachedRoot.Title, "auth") {
|
||||||
|
t.Errorf("Attached root title %q should contain 'auth' from variable substitution", attachedRoot.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpawnWithMultipleAttachments tests spawning with --attach A --attach B
|
||||||
|
func TestSpawnWithMultipleAttachments(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dbPath := t.TempDir() + "/test.db"
|
||||||
|
s, err := sqlite.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to set config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create primary proto
|
||||||
|
primaryProto := &types.Issue{
|
||||||
|
Title: "Primary Feature",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeEpic,
|
||||||
|
Labels: []string{MoleculeLabel},
|
||||||
|
}
|
||||||
|
if err := s.CreateIssue(ctx, primaryProto, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create primary proto: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create first attachment proto
|
||||||
|
attachA := &types.Issue{
|
||||||
|
Title: "Attachment A: Testing",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeEpic,
|
||||||
|
Labels: []string{MoleculeLabel},
|
||||||
|
}
|
||||||
|
if err := s.CreateIssue(ctx, attachA, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create attachA: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create second attachment proto
|
||||||
|
attachB := &types.Issue{
|
||||||
|
Title: "Attachment B: Documentation",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 3,
|
||||||
|
IssueType: types.TypeEpic,
|
||||||
|
Labels: []string{MoleculeLabel},
|
||||||
|
}
|
||||||
|
if err := s.CreateIssue(ctx, attachB, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create attachB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn primary
|
||||||
|
primarySubgraph, err := loadTemplateSubgraph(ctx, s, primaryProto.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load primary subgraph: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnResult, err := spawnMolecule(ctx, s, primarySubgraph, nil, "", "test", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to spawn primary: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnedMol, err := s.GetIssue(ctx, spawnResult.NewEpicID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get spawned molecule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach both protos (simulating --attach A --attach B)
|
||||||
|
bondResultA, err := bondProtoMol(ctx, s, attachA, spawnedMol, types.BondTypeSequential, nil, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to bond attachA: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bondResultB, err := bondProtoMol(ctx, s, attachB, spawnedMol, types.BondTypeSequential, nil, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to bond attachB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both should have spawned their protos
|
||||||
|
if bondResultA.Spawned != 1 {
|
||||||
|
t.Errorf("bondResultA.Spawned = %d, want 1", bondResultA.Spawned)
|
||||||
|
}
|
||||||
|
if bondResultB.Spawned != 1 {
|
||||||
|
t.Errorf("bondResultB.Spawned = %d, want 1", bondResultB.Spawned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both should depend on the primary molecule
|
||||||
|
attachedAID := bondResultA.IDMapping[attachA.ID]
|
||||||
|
attachedBID := bondResultB.IDMapping[attachB.ID]
|
||||||
|
|
||||||
|
depsA, err := s.GetDependenciesWithMetadata(ctx, attachedAID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get deps for A: %v", err)
|
||||||
|
}
|
||||||
|
depsB, err := s.GetDependenciesWithMetadata(ctx, attachedBID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get deps for B: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
foundABlocks := false
|
||||||
|
for _, dep := range depsA {
|
||||||
|
if dep.ID == spawnedMol.ID && dep.DependencyType == types.DepBlocks {
|
||||||
|
foundABlocks = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foundBBlocks := false
|
||||||
|
for _, dep := range depsB {
|
||||||
|
if dep.ID == spawnedMol.ID && dep.DependencyType == types.DepBlocks {
|
||||||
|
foundBBlocks = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundABlocks {
|
||||||
|
t.Error("Expected A to block on spawned molecule")
|
||||||
|
}
|
||||||
|
if !foundBBlocks {
|
||||||
|
t.Error("Expected B to block on spawned molecule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpawnAttachTypes verifies sequential vs parallel bonding behavior
|
||||||
|
func TestSpawnAttachTypes(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dbPath := t.TempDir() + "/test.db"
|
||||||
|
s, err := sqlite.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to set config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create primary proto
|
||||||
|
primaryProto := &types.Issue{
|
||||||
|
Title: "Primary",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeEpic,
|
||||||
|
Labels: []string{MoleculeLabel},
|
||||||
|
}
|
||||||
|
if err := s.CreateIssue(ctx, primaryProto, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create primary: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create attachment proto
|
||||||
|
attachProto := &types.Issue{
|
||||||
|
Title: "Attachment",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeEpic,
|
||||||
|
Labels: []string{MoleculeLabel},
|
||||||
|
}
|
||||||
|
if err := s.CreateIssue(ctx, attachProto, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create attachment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
bondType string
|
||||||
|
expectType types.DependencyType
|
||||||
|
}{
|
||||||
|
{"sequential uses blocks", types.BondTypeSequential, types.DepBlocks},
|
||||||
|
{"parallel uses parent-child", types.BondTypeParallel, types.DepParentChild},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Spawn fresh primary for each test
|
||||||
|
primarySubgraph, err := loadTemplateSubgraph(ctx, s, primaryProto.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load primary subgraph: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnResult, err := spawnMolecule(ctx, s, primarySubgraph, nil, "", "test", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to spawn primary: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnedMol, err := s.GetIssue(ctx, spawnResult.NewEpicID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get spawned molecule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bond with specified type
|
||||||
|
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, tt.bondType, nil, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to bond: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check dependency type
|
||||||
|
attachedID := bondResult.IDMapping[attachProto.ID]
|
||||||
|
deps, err := s.GetDependenciesWithMetadata(ctx, attachedID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get deps: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
foundExpected := false
|
||||||
|
for _, dep := range deps {
|
||||||
|
if dep.ID == spawnedMol.ID && dep.DependencyType == tt.expectType {
|
||||||
|
foundExpected = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundExpected {
|
||||||
|
t.Errorf("Expected %s dependency from attached to spawned molecule", tt.expectType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpawnAttachNonProtoError tests that attaching a non-proto fails validation
|
||||||
|
func TestSpawnAttachNonProtoError(t *testing.T) {
|
||||||
|
// The isProto function is tested separately in TestIsProto
|
||||||
|
// This test verifies the validation logic that would be used in runMolSpawn
|
||||||
|
|
||||||
|
// Create a non-proto issue (no template label)
|
||||||
|
issue := &types.Issue{
|
||||||
|
Title: "Not a proto",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Labels: []string{"bug"}, // Not MoleculeLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
if isProto(issue) {
|
||||||
|
t.Error("isProto should return false for issue without template label")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue with template label should pass
|
||||||
|
protoIssue := &types.Issue{
|
||||||
|
Title: "A proto",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Labels: []string{MoleculeLabel},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isProto(protoIssue) {
|
||||||
|
t.Error("isProto should return true for issue with template label")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpawnVariableAggregation tests that variables from primary + attachments are combined
|
||||||
|
func TestSpawnVariableAggregation(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dbPath := t.TempDir() + "/test.db"
|
||||||
|
s, err := sqlite.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to set config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create primary proto with one variable
|
||||||
|
primaryProto := &types.Issue{
|
||||||
|
Title: "Feature: {{feature_name}}",
|
||||||
|
Description: "Implement the {{feature_name}} feature",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeEpic,
|
||||||
|
Labels: []string{MoleculeLabel},
|
||||||
|
}
|
||||||
|
if err := s.CreateIssue(ctx, primaryProto, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create primary: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create attachment proto with a different variable
|
||||||
|
attachProto := &types.Issue{
|
||||||
|
Title: "Docs for {{doc_version}}",
|
||||||
|
Description: "Document version {{doc_version}}",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeEpic,
|
||||||
|
Labels: []string{MoleculeLabel},
|
||||||
|
}
|
||||||
|
if err := s.CreateIssue(ctx, attachProto, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create attachment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load subgraphs and extract variables
|
||||||
|
primarySubgraph, err := loadTemplateSubgraph(ctx, s, primaryProto.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load primary subgraph: %v", err)
|
||||||
|
}
|
||||||
|
attachSubgraph, err := loadTemplateSubgraph(ctx, s, attachProto.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load attach subgraph: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate variables (simulating runMolSpawn logic)
|
||||||
|
requiredVars := extractAllVariables(primarySubgraph)
|
||||||
|
attachVars := extractAllVariables(attachSubgraph)
|
||||||
|
for _, v := range attachVars {
|
||||||
|
found := false
|
||||||
|
for _, rv := range requiredVars {
|
||||||
|
if rv == v {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
requiredVars = append(requiredVars, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have both variables
|
||||||
|
if len(requiredVars) != 2 {
|
||||||
|
t.Errorf("Expected 2 required vars, got %d: %v", len(requiredVars), requiredVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFeatureName := false
|
||||||
|
hasDocVersion := false
|
||||||
|
for _, v := range requiredVars {
|
||||||
|
if v == "feature_name" {
|
||||||
|
hasFeatureName = true
|
||||||
|
}
|
||||||
|
if v == "doc_version" {
|
||||||
|
hasDocVersion = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasFeatureName {
|
||||||
|
t.Error("Missing feature_name variable from primary proto")
|
||||||
|
}
|
||||||
|
if !hasDocVersion {
|
||||||
|
t.Error("Missing doc_version variable from attachment proto")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide both variables and verify substitution
|
||||||
|
vars := map[string]string{
|
||||||
|
"feature_name": "authentication",
|
||||||
|
"doc_version": "2.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn primary with variables
|
||||||
|
spawnResult, err := spawnMolecule(ctx, s, primarySubgraph, vars, "", "test", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to spawn primary: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify primary variable was substituted
|
||||||
|
spawnedPrimary, err := s.GetIssue(ctx, spawnResult.NewEpicID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get spawned primary: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(spawnedPrimary.Title, "authentication") {
|
||||||
|
t.Errorf("Primary title %q should contain 'authentication'", spawnedPrimary.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bond attachment with same variables
|
||||||
|
spawnedMol, _ := s.GetIssue(ctx, spawnResult.NewEpicID)
|
||||||
|
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, types.BondTypeSequential, vars, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to bond: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify attachment variable was substituted
|
||||||
|
attachedID := bondResult.IDMapping[attachProto.ID]
|
||||||
|
attachedIssue, err := s.GetIssue(ctx, attachedID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get attached issue: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(attachedIssue.Title, "2.0") {
|
||||||
|
t.Errorf("Attached title %q should contain '2.0'", attachedIssue.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpawnAttachDryRunOutput tests that dry-run includes attachment info
|
||||||
|
// This is a lighter test since dry-run is mainly a CLI output concern
|
||||||
|
func TestSpawnAttachDryRunOutput(t *testing.T) {
|
||||||
|
// The dry-run logic in runMolSpawn outputs attachment info when len(attachments) > 0
|
||||||
|
// We verify the data structures that would be used in dry-run
|
||||||
|
|
||||||
|
type attachmentInfo struct {
|
||||||
|
id string
|
||||||
|
title string
|
||||||
|
subgraph *MoleculeSubgraph
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate the attachment info collection
|
||||||
|
attachments := []attachmentInfo{
|
||||||
|
{id: "test-1", title: "Attachment 1", subgraph: &MoleculeSubgraph{
|
||||||
|
Issues: []*types.Issue{{Title: "Issue A"}, {Title: "Issue B"}},
|
||||||
|
}},
|
||||||
|
{id: "test-2", title: "Attachment 2", subgraph: &MoleculeSubgraph{
|
||||||
|
Issues: []*types.Issue{{Title: "Issue C"}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify attachment count calculation (used in dry-run output)
|
||||||
|
totalAttachmentIssues := 0
|
||||||
|
for _, attach := range attachments {
|
||||||
|
totalAttachmentIssues += len(attach.subgraph.Issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalAttachmentIssues != 3 {
|
||||||
|
t.Errorf("Expected 3 total attachment issues, got %d", totalAttachmentIssues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify bond type would be included (sequential is default)
|
||||||
|
attachType := types.BondTypeSequential
|
||||||
|
if attachType != "sequential" {
|
||||||
|
t.Errorf("Expected default attach type 'sequential', got %q", attachType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestWispFilteringFromExport verifies that wisp issues are filtered
|
// TestWispFilteringFromExport verifies that wisp issues are filtered
|
||||||
// from JSONL export (bd-687g). Wisp issues should only exist in SQLite,
|
// from JSONL export (bd-687g). Wisp issues should only exist in SQLite,
|
||||||
// not in issues.jsonl, to prevent "zombie" resurrection after mol squash.
|
// not in issues.jsonl, to prevent "zombie" resurrection after mol squash.
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ func (s *SQLiteStorage) UnderlyingConn(ctx context.Context) (*sql.Conn, error) {
|
|||||||
// - Makes database safe for backup/copy operations
|
// - Makes database safe for backup/copy operations
|
||||||
func (s *SQLiteStorage) CheckpointWAL(ctx context.Context) error {
|
func (s *SQLiteStorage) CheckpointWAL(ctx context.Context) error {
|
||||||
_, err := s.db.ExecContext(ctx, "PRAGMA wal_checkpoint(FULL)")
|
_, err := s.db.ExecContext(ctx, "PRAGMA wal_checkpoint(FULL)")
|
||||||
return err
|
return wrapDBError("checkpoint WAL", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnableFreshnessChecking enables detection of external database file modifications.
|
// EnableFreshnessChecking enables detection of external database file modifications.
|
||||||
|
|||||||
Reference in New Issue
Block a user