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>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
// Package main implements the bd CLI dependency repair command.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -5,158 +6,170 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
var repairDepsCmd = &cobra.Command{
|
||||
Use: "repair-deps",
|
||||
Short: "Find and fix orphaned dependency references",
|
||||
Long: `Find issues that reference non-existent dependencies and optionally remove them.
|
||||
|
||||
This command scans all issues for dependency references (both blocks and related-to)
|
||||
that point to issues that no longer exist in the database.
|
||||
|
||||
Example:
|
||||
bd repair-deps # Show orphaned dependencies
|
||||
bd repair-deps --fix # Remove orphaned references
|
||||
bd repair-deps --json # Output in JSON format`,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
// Check daemon mode - not supported yet (uses direct storage access)
|
||||
if daemonClient != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: repair-deps command not yet supported in daemon mode\n")
|
||||
fmt.Fprintf(os.Stderr, "Use: bd --no-daemon repair-deps\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
Long: `Scans all issues for dependencies pointing to non-existent issues.
|
||||
|
||||
Reports orphaned dependencies and optionally removes them with --fix.
|
||||
Interactive mode with --interactive prompts for each orphan.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fix, _ := cmd.Flags().GetBool("fix")
|
||||
interactive, _ := cmd.Flags().GetBool("interactive")
|
||||
|
||||
// If daemon is running but doesn't support this command, use direct storage
|
||||
if daemonClient != nil && store == nil {
|
||||
var err error
|
||||
store, err = sqlite.New(dbPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = store.Close() }()
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Get all issues
|
||||
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
// Get all dependency records
|
||||
allDeps, err := store.GetAllDependencyRecords(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error fetching issues: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to get dependencies: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Build ID existence map
|
||||
existingIDs := make(map[string]bool)
|
||||
for _, issue := range allIssues {
|
||||
existingIDs[issue.ID] = true
|
||||
// Get all issues to check existence
|
||||
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to list issues: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Build set of valid issue IDs
|
||||
validIDs := make(map[string]bool)
|
||||
for _, issue := range issues {
|
||||
validIDs[issue.ID] = true
|
||||
}
|
||||
|
||||
// Find orphaned dependencies
|
||||
type orphanedDep struct {
|
||||
IssueID string
|
||||
OrphanedID string
|
||||
DepType string
|
||||
type orphan struct {
|
||||
issueID string
|
||||
dependsOnID string
|
||||
depType types.DependencyType
|
||||
}
|
||||
|
||||
var orphaned []orphanedDep
|
||||
var orphans []orphan
|
||||
|
||||
for _, issue := range allIssues {
|
||||
// Check dependencies
|
||||
for _, dep := range issue.Dependencies {
|
||||
if !existingIDs[dep.DependsOnID] {
|
||||
orphaned = append(orphaned, orphanedDep{
|
||||
IssueID: issue.ID,
|
||||
OrphanedID: dep.DependsOnID,
|
||||
DepType: string(dep.Type),
|
||||
for issueID, deps := range allDeps {
|
||||
if !validIDs[issueID] {
|
||||
// The issue itself doesn't exist, skip (will be cleaned up separately)
|
||||
continue
|
||||
}
|
||||
for _, dep := range deps {
|
||||
if !validIDs[dep.DependsOnID] {
|
||||
orphans = append(orphans, orphan{
|
||||
issueID: dep.IssueID,
|
||||
dependsOnID: dep.DependsOnID,
|
||||
depType: dep.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Output results
|
||||
if jsonOutput {
|
||||
result := map[string]interface{}{
|
||||
"orphaned_count": len(orphaned),
|
||||
"fixed": fix,
|
||||
"orphaned_deps": []map[string]interface{}{},
|
||||
"orphans_found": len(orphans),
|
||||
"orphans": []map[string]string{},
|
||||
}
|
||||
|
||||
for _, o := range orphaned {
|
||||
result["orphaned_deps"] = append(result["orphaned_deps"].([]map[string]interface{}), map[string]interface{}{
|
||||
"issue_id": o.IssueID,
|
||||
"orphaned_id": o.OrphanedID,
|
||||
"dep_type": o.DepType,
|
||||
})
|
||||
if len(orphans) > 0 {
|
||||
orphanList := make([]map[string]string, len(orphans))
|
||||
for i, o := range orphans {
|
||||
orphanList[i] = map[string]string{
|
||||
"issue_id": o.issueID,
|
||||
"depends_on_id": o.dependsOnID,
|
||||
"type": string(o.depType),
|
||||
}
|
||||
}
|
||||
result["orphans"] = orphanList
|
||||
}
|
||||
if fix || interactive {
|
||||
result["fixed"] = len(orphans)
|
||||
}
|
||||
|
||||
outputJSON(result)
|
||||
return
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
if len(orphaned) == 0 {
|
||||
fmt.Println("No orphaned dependencies found!")
|
||||
// Report results
|
||||
if len(orphans) == 0 {
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
fmt.Printf("\n%s No orphaned dependencies found\n\n", green("✓"))
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d orphaned dependencies:\n\n", len(orphaned))
|
||||
for _, o := range orphaned {
|
||||
fmt.Printf(" %s: depends on %s (%s) - DELETED\n", o.IssueID, o.OrphanedID, o.DepType)
|
||||
yellow := color.New(color.FgYellow).SprintFunc()
|
||||
fmt.Printf("\n%s Found %d orphaned dependencies:\n\n", yellow("⚠"), len(orphans))
|
||||
|
||||
for i, o := range orphans {
|
||||
fmt.Printf("%d. %s → %s (%s) [%s does not exist]\n",
|
||||
i+1, o.issueID, o.dependsOnID, o.depType, o.dependsOnID)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if !fix {
|
||||
fmt.Printf("\nRun 'bd repair-deps --fix' to remove these references.\n")
|
||||
return
|
||||
}
|
||||
|
||||
// Fix orphaned dependencies
|
||||
fmt.Printf("\nRemoving orphaned dependencies...\n")
|
||||
|
||||
// Group by issue for efficient updates
|
||||
orphansByIssue := make(map[string][]string)
|
||||
for _, o := range orphaned {
|
||||
orphansByIssue[o.IssueID] = append(orphansByIssue[o.IssueID], o.OrphanedID)
|
||||
}
|
||||
|
||||
fixed := 0
|
||||
for issueID, orphanedIDs := range orphansByIssue {
|
||||
// Get current issue to verify
|
||||
issue, err := store.GetIssue(ctx, issueID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", issueID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Collect orphaned dependency IDs to remove
|
||||
orphanedSet := make(map[string]bool)
|
||||
for _, orphanedID := range orphanedIDs {
|
||||
orphanedSet[orphanedID] = true
|
||||
}
|
||||
|
||||
// Build list of dependencies to keep
|
||||
validDeps := []*types.Dependency{}
|
||||
for _, dep := range issue.Dependencies {
|
||||
if !orphanedSet[dep.DependsOnID] {
|
||||
validDeps = append(validDeps, dep)
|
||||
// Fix if requested
|
||||
if interactive {
|
||||
fixed := 0
|
||||
for _, o := range orphans {
|
||||
fmt.Printf("Remove dependency %s → %s (%s)? [y/N]: ", o.issueID, o.dependsOnID, o.depType)
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
if response == "y" || response == "Y" {
|
||||
// Use direct SQL to remove orphaned dependencies
|
||||
// RemoveDependency tries to mark the depends_on issue as dirty, which fails for orphans
|
||||
db := store.UnderlyingDB()
|
||||
_, err := db.ExecContext(ctx, "DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?",
|
||||
o.issueID, o.dependsOnID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error removing dependency: %v\n", err)
|
||||
} else {
|
||||
// Mark the issue as dirty
|
||||
_, _ = db.ExecContext(ctx, "INSERT OR IGNORE INTO dirty_issues (issue_id) VALUES (?)", o.issueID)
|
||||
fixed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update via storage layer
|
||||
// We need to remove each orphaned dependency individually
|
||||
for _, orphanedID := range orphanedIDs {
|
||||
if err := store.RemoveDependency(ctx, issueID, orphanedID, actor); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error removing %s from %s: %v\n", orphanedID, issueID, err)
|
||||
continue
|
||||
markDirtyAndScheduleFlush()
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
fmt.Printf("\n%s Fixed %d orphaned dependencies\n\n", green("✓"), fixed)
|
||||
} else if fix {
|
||||
db := store.UnderlyingDB()
|
||||
for _, o := range orphans {
|
||||
// Use direct SQL to remove orphaned dependencies
|
||||
_, err := db.ExecContext(ctx, "DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?",
|
||||
o.issueID, o.dependsOnID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error removing dependency %s → %s: %v\n",
|
||||
o.issueID, o.dependsOnID, err)
|
||||
} else {
|
||||
// Mark the issue as dirty
|
||||
_, _ = db.ExecContext(ctx, "INSERT OR IGNORE INTO dirty_issues (issue_id) VALUES (?)", o.issueID)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Removed %s from %s dependencies\n", orphanedID, issueID)
|
||||
fixed++
|
||||
}
|
||||
markDirtyAndScheduleFlush()
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
fmt.Printf("%s Fixed %d orphaned dependencies\n\n", green("✓"), len(orphans))
|
||||
} else {
|
||||
fmt.Printf("Run with --fix to automatically remove orphaned dependencies\n")
|
||||
fmt.Printf("Run with --interactive to review each dependency\n\n")
|
||||
}
|
||||
|
||||
// Schedule auto-flush
|
||||
markDirtyAndScheduleFlush()
|
||||
|
||||
fmt.Printf("\nRepaired %d orphaned dependencies.\n", fixed)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
repairDepsCmd.Flags().Bool("fix", false, "Remove orphaned dependency references")
|
||||
repairDepsCmd.Flags().Bool("fix", false, "Automatically remove orphaned dependencies")
|
||||
repairDepsCmd.Flags().Bool("interactive", false, "Interactively review each orphaned dependency")
|
||||
rootCmd.AddCommand(repairDepsCmd)
|
||||
}
|
||||
|
||||
393
cmd/bd/repair_deps_test.go
Normal file
393
cmd/bd/repair_deps_test.go
Normal file
@@ -0,0 +1,393 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user