Files
beads/cmd/bd/repair_deps_test.go
Steve Yegge da9773c31b Add bd repair-deps command (bd-58)
- Scans all issues for dependencies pointing to non-existent issues
- Reports orphaned dependencies with issue ID, depends_on ID, and type
- --fix flag automatically removes all orphaned dependencies
- --interactive mode prompts for each orphan before removal
- Uses direct SQL deletion to avoid foreign key errors on missing issues
- JSON output support with --json flag
- 4 comprehensive tests covering scan, fix, and multiple dependency types

Amp-Thread-ID: https://ampcode.com/threads/T-942a3e75-f90b-45b4-9f88-c7f1b8298cef
Co-authored-by: Amp <amp@ampcode.com>
2025-10-29 12:56:51 -07:00

394 lines
10 KiB
Go

package main
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
func TestRepairDeps_NoOrphans(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, ".beads", "beads.db")
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
t.Fatal(err)
}
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatal(err)
}
defer store.Close()
ctx := context.Background()
// Initialize database
store.SetConfig(ctx, "issue_prefix", "test-")
// Create two issues with valid dependency
i1 := &types.Issue{Title: "Issue 1", Priority: 1, Status: "open", IssueType: "task"}
store.CreateIssue(ctx, i1, "test")
i2 := &types.Issue{Title: "Issue 2", Priority: 1, Status: "open", IssueType: "task"}
store.CreateIssue(ctx, i2, "test")
store.AddDependency(ctx, &types.Dependency{
IssueID: i2.ID,
DependsOnID: i1.ID,
Type: types.DepBlocks,
}, "test")
// Get all dependency records
allDeps, err := store.GetAllDependencyRecords(ctx)
if err != nil {
t.Fatal(err)
}
// Get all issues
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
t.Fatal(err)
}
// Build valid ID set
validIDs := make(map[string]bool)
for _, issue := range issues {
validIDs[issue.ID] = true
}
// Find orphans
orphanCount := 0
for issueID, deps := range allDeps {
if !validIDs[issueID] {
continue
}
for _, dep := range deps {
if !validIDs[dep.DependsOnID] {
orphanCount++
}
}
}
if orphanCount != 0 {
t.Errorf("Expected 0 orphans, got %d", orphanCount)
}
}
func TestRepairDeps_FindOrphans(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, ".beads", "beads.db")
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
t.Fatal(err)
}
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatal(err)
}
defer store.Close()
ctx := context.Background()
// Initialize database
store.SetConfig(ctx, "issue_prefix", "test-")
// Create two issues
i1 := &types.Issue{Title: "Issue 1", Priority: 1, Status: "open", IssueType: "task"}
if err := store.CreateIssue(ctx, i1, "test"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
t.Logf("Created i1: %s", i1.ID)
i2 := &types.Issue{Title: "Issue 2", Priority: 1, Status: "open", IssueType: "task"}
if err := store.CreateIssue(ctx, i2, "test"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
t.Logf("Created i2: %s", i2.ID)
// Add dependency
err = store.AddDependency(ctx, &types.Dependency{
IssueID: i2.ID,
DependsOnID: i1.ID,
Type: types.DepBlocks,
}, "test")
if err != nil {
t.Fatalf("AddDependency failed: %v", err)
}
// Manually create orphaned dependency by directly inserting invalid reference
// This simulates corruption or import errors
db := store.UnderlyingDB()
_, err = db.ExecContext(ctx, "PRAGMA foreign_keys = OFF")
if err != nil {
t.Fatal(err)
}
// Insert a dependency pointing to a non-existent issue
_, err = db.ExecContext(ctx, `INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
VALUES (?, 'nonexistent-123', 'blocks', datetime('now'), 'test')`, i2.ID)
if err != nil {
t.Fatalf("Failed to insert orphaned dependency: %v", err)
}
_, err = db.ExecContext(ctx, "PRAGMA foreign_keys = ON")
if err != nil {
t.Fatal(err)
}
// Verify the orphan was actually inserted
var count int
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dependencies WHERE depends_on_id = 'nonexistent-123'").Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("Orphan dependency not inserted, count=%d", count)
}
// Get all dependency records
allDeps, err := store.GetAllDependencyRecords(ctx)
if err != nil {
t.Fatal(err)
}
t.Logf("Got %d issues with dependencies", len(allDeps))
for issueID, deps := range allDeps {
t.Logf("Issue %s has %d dependencies", issueID, len(deps))
for _, dep := range deps {
t.Logf(" -> %s (%s)", dep.DependsOnID, dep.Type)
}
}
// Get all issues
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
t.Fatal(err)
}
// Build valid ID set
validIDs := make(map[string]bool)
for _, issue := range issues {
validIDs[issue.ID] = true
}
t.Logf("Valid issue IDs: %v", validIDs)
// Find orphans
orphanCount := 0
for issueID, deps := range allDeps {
if !validIDs[issueID] {
t.Logf("Skipping %s - issue itself doesn't exist", issueID)
continue
}
for _, dep := range deps {
if !validIDs[dep.DependsOnID] {
t.Logf("Found orphan: %s -> %s", dep.IssueID, dep.DependsOnID)
orphanCount++
}
}
}
if orphanCount != 1 {
t.Errorf("Expected 1 orphan, got %d", orphanCount)
}
}
func TestRepairDeps_FixOrphans(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, ".beads", "beads.db")
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
t.Fatal(err)
}
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatal(err)
}
defer store.Close()
ctx := context.Background()
// Initialize database
store.SetConfig(ctx, "issue_prefix", "test-")
// Create three issues
i1 := &types.Issue{Title: "Issue 1", Priority: 1, Status: "open", IssueType: "task"}
store.CreateIssue(ctx, i1, "test")
i2 := &types.Issue{Title: "Issue 2", Priority: 1, Status: "open", IssueType: "task"}
store.CreateIssue(ctx, i2, "test")
i3 := &types.Issue{Title: "Issue 3", Priority: 1, Status: "open", IssueType: "task"}
store.CreateIssue(ctx, i3, "test")
// Add dependencies
store.AddDependency(ctx, &types.Dependency{
IssueID: i2.ID,
DependsOnID: i1.ID,
Type: types.DepBlocks,
}, "test")
store.AddDependency(ctx, &types.Dependency{
IssueID: i3.ID,
DependsOnID: i1.ID,
Type: types.DepBlocks,
}, "test")
// Manually create orphaned dependencies by inserting invalid references
db := store.UnderlyingDB()
db.Exec("PRAGMA foreign_keys = OFF")
_, err = db.ExecContext(ctx, `INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
VALUES (?, 'nonexistent-123', 'blocks', datetime('now'), 'test')`, i2.ID)
if err != nil {
t.Fatal(err)
}
_, err = db.ExecContext(ctx, `INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
VALUES (?, 'nonexistent-456', 'blocks', datetime('now'), 'test')`, i3.ID)
if err != nil {
t.Fatal(err)
}
db.Exec("PRAGMA foreign_keys = ON")
// Find and fix orphans
allDeps, _ := store.GetAllDependencyRecords(ctx)
issues, _ := store.SearchIssues(ctx, "", types.IssueFilter{})
validIDs := make(map[string]bool)
for _, issue := range issues {
validIDs[issue.ID] = true
}
type orphan struct {
issueID string
dependsOnID string
}
var orphans []orphan
for issueID, deps := range allDeps {
if !validIDs[issueID] {
continue
}
for _, dep := range deps {
if !validIDs[dep.DependsOnID] {
orphans = append(orphans, orphan{
issueID: dep.IssueID,
dependsOnID: dep.DependsOnID,
})
}
}
}
if len(orphans) != 2 {
t.Fatalf("Expected 2 orphans before fix, got %d", len(orphans))
}
// Fix orphans using direct SQL (like the command does)
for _, o := range orphans {
_, delErr := db.ExecContext(ctx, "DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?",
o.issueID, o.dependsOnID)
if delErr != nil {
t.Errorf("Failed to remove orphan: %v", delErr)
}
}
// Verify orphans removed
allDeps, _ = store.GetAllDependencyRecords(ctx)
orphanCount := 0
for issueID, deps := range allDeps {
if !validIDs[issueID] {
continue
}
for _, dep := range deps {
if !validIDs[dep.DependsOnID] {
orphanCount++
}
}
}
if orphanCount != 0 {
t.Errorf("Expected 0 orphans after fix, got %d", orphanCount)
}
}
func TestRepairDeps_MultipleTypes(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, ".beads", "beads.db")
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
t.Fatal(err)
}
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatal(err)
}
defer store.Close()
ctx := context.Background()
// Initialize database
store.SetConfig(ctx, "issue_prefix", "test-")
// Create issues
i1 := &types.Issue{Title: "Issue 1", Priority: 1, Status: "open", IssueType: "task"}
store.CreateIssue(ctx, i1, "test")
i2 := &types.Issue{Title: "Issue 2", Priority: 1, Status: "open", IssueType: "task"}
store.CreateIssue(ctx, i2, "test")
i3 := &types.Issue{Title: "Issue 3", Priority: 1, Status: "open", IssueType: "task"}
store.CreateIssue(ctx, i3, "test")
// Add different dependency types
store.AddDependency(ctx, &types.Dependency{
IssueID: i2.ID,
DependsOnID: i1.ID,
Type: types.DepBlocks,
}, "test")
store.AddDependency(ctx, &types.Dependency{
IssueID: i3.ID,
DependsOnID: i1.ID,
Type: types.DepRelated,
}, "test")
// Manually create orphaned dependencies with different types
db := store.UnderlyingDB()
db.Exec("PRAGMA foreign_keys = OFF")
_, err = db.ExecContext(ctx, `INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
VALUES (?, 'nonexistent-blocks', 'blocks', datetime('now'), 'test')`, i2.ID)
if err != nil {
t.Fatal(err)
}
_, err = db.ExecContext(ctx, `INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
VALUES (?, 'nonexistent-related', 'related', datetime('now'), 'test')`, i3.ID)
if err != nil {
t.Fatal(err)
}
db.Exec("PRAGMA foreign_keys = ON")
// Find orphans
allDeps, _ := store.GetAllDependencyRecords(ctx)
issues, _ := store.SearchIssues(ctx, "", types.IssueFilter{})
validIDs := make(map[string]bool)
for _, issue := range issues {
validIDs[issue.ID] = true
}
orphanCount := 0
depTypes := make(map[types.DependencyType]int)
for issueID, deps := range allDeps {
if !validIDs[issueID] {
continue
}
for _, dep := range deps {
if !validIDs[dep.DependsOnID] {
orphanCount++
depTypes[dep.Type]++
}
}
}
if orphanCount != 2 {
t.Errorf("Expected 2 orphans, got %d", orphanCount)
}
if depTypes[types.DepBlocks] != 1 {
t.Errorf("Expected 1 blocks orphan, got %d", depTypes[types.DepBlocks])
}
if depTypes[types.DepRelated] != 1 {
t.Errorf("Expected 1 related orphan, got %d", depTypes[types.DepRelated])
}
}