diff --git a/cmd/bd/repair.go b/cmd/bd/repair.go index debfdd6f..6868bbba 100644 --- a/cmd/bd/repair.go +++ b/cmd/bd/repair.go @@ -2,6 +2,7 @@ package main import ( "database/sql" + "encoding/json" "fmt" "os" "path/filepath" @@ -30,31 +31,53 @@ This command opens SQLite directly (bypassing invariant checks) and cleans: - Orphaned dependencies (issue_id not in issues) - Orphaned dependencies (depends_on_id not in issues, excluding external refs) - Orphaned labels (issue_id not in issues) + - Orphaned comments (issue_id not in issues) + - Orphaned events (issue_id not in issues) After repair, normal bd commands should work again. Examples: bd repair # Repair database in current directory bd repair --dry-run # Show what would be cleaned without making changes - bd repair --path /other/repo # Repair database in another location`, + bd repair --path /other/repo # Repair database in another location + bd repair --json # Output results as JSON`, Run: runRepair, } var ( repairDryRun bool repairPath string + repairJSON bool ) func init() { repairCmd.Flags().BoolVar(&repairDryRun, "dry-run", false, "Show what would be cleaned without making changes") repairCmd.Flags().StringVar(&repairPath, "path", ".", "Path to repository with .beads directory") + repairCmd.Flags().BoolVar(&repairJSON, "json", false, "Output results as JSON") rootCmd.AddCommand(repairCmd) } +// outputJSONAndExit outputs the repair result as JSON and exits +func outputJSONAndExit(result repairResult, exitCode int) { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, `{"error": "failed to marshal JSON: %v"}`, err) + os.Exit(1) + } + fmt.Println(string(data)) + os.Exit(exitCode) +} + func runRepair(cmd *cobra.Command, args []string) { // Find .beads directory beadsDir := filepath.Join(repairPath, ".beads") if _, err := os.Stat(beadsDir); os.IsNotExist(err) { + if repairJSON { + outputJSONAndExit(repairResult{ + Status: "error", + Error: fmt.Sprintf(".beads directory not found at %s", beadsDir), + }, 1) + } fmt.Fprintf(os.Stderr, "Error: .beads directory not found at %s\n", beadsDir) os.Exit(1) } @@ -62,19 +85,34 @@ func runRepair(cmd *cobra.Command, args []string) { // Find database file dbPath := filepath.Join(beadsDir, "beads.db") if _, err := os.Stat(dbPath); os.IsNotExist(err) { + if repairJSON { + outputJSONAndExit(repairResult{ + Status: "error", + Error: fmt.Sprintf("database not found at %s", dbPath), + }, 1) + } fmt.Fprintf(os.Stderr, "Error: database not found at %s\n", dbPath) os.Exit(1) } - fmt.Printf("Repairing database: %s\n", dbPath) - if repairDryRun { - fmt.Println("[DRY-RUN] No changes will be made") + if !repairJSON { + fmt.Printf("Repairing database: %s\n", dbPath) + if repairDryRun { + fmt.Println("[DRY-RUN] No changes will be made") + } + fmt.Println() } - fmt.Println() // Open database directly, bypassing beads storage layer db, err := openRepairDB(dbPath) if err != nil { + if repairJSON { + outputJSONAndExit(repairResult{ + DatabasePath: dbPath, + Status: "error", + Error: fmt.Sprintf("opening database: %v", err), + }, 1) + } fmt.Fprintf(os.Stderr, "Error opening database: %v\n", err) os.Exit(1) } @@ -107,53 +145,146 @@ func runRepair(cmd *cobra.Command, args []string) { } stats.orphanedLabels = len(orphanedLabels) - // Print findings + // 4. Find and clean orphaned comments + orphanedCommentsList, err := findOrphanedComments(db) + if err != nil { + fmt.Fprintf(os.Stderr, "Error checking orphaned comments: %v\n", err) + os.Exit(1) + } + stats.orphanedComments = len(orphanedCommentsList) + + // 5. Find and clean orphaned events + orphanedEventsList, err := findOrphanedEvents(db) + if err != nil { + fmt.Fprintf(os.Stderr, "Error checking orphaned events: %v\n", err) + os.Exit(1) + } + stats.orphanedEvents = len(orphanedEventsList) + + // Build JSON result structure (used for both JSON output and tracking) + jsonResult := repairResult{ + DatabasePath: dbPath, + DryRun: repairDryRun, + OrphanCounts: repairOrphanCounts{ + DependenciesIssueID: stats.orphanedDepsIssueID, + DependenciesDependsOn: stats.orphanedDepsDependsOn, + Labels: stats.orphanedLabels, + Comments: stats.orphanedComments, + Events: stats.orphanedEvents, + Total: stats.total(), + }, + } + + // Build orphan details + for _, dep := range orphanedIssueID { + jsonResult.OrphanDetails.DependenciesIssueID = append(jsonResult.OrphanDetails.DependenciesIssueID, + repairDepDetail{IssueID: dep.issueID, DependsOnID: dep.dependsOnID}) + } + for _, dep := range orphanedDependsOn { + jsonResult.OrphanDetails.DependenciesDependsOn = append(jsonResult.OrphanDetails.DependenciesDependsOn, + repairDepDetail{IssueID: dep.issueID, DependsOnID: dep.dependsOnID}) + } + for _, label := range orphanedLabels { + jsonResult.OrphanDetails.Labels = append(jsonResult.OrphanDetails.Labels, + repairLabelDetail{IssueID: label.issueID, Label: label.label}) + } + for _, comment := range orphanedCommentsList { + jsonResult.OrphanDetails.Comments = append(jsonResult.OrphanDetails.Comments, + repairCommentDetail{ID: comment.id, IssueID: comment.issueID, Author: comment.author}) + } + for _, event := range orphanedEventsList { + jsonResult.OrphanDetails.Events = append(jsonResult.OrphanDetails.Events, + repairEventDetail{ID: event.id, IssueID: event.issueID, EventType: event.eventType}) + } + + // Handle no orphans case if stats.total() == 0 { + if repairJSON { + jsonResult.Status = "no_orphans" + outputJSONAndExit(jsonResult, 0) + } fmt.Printf("%s No orphaned references found - database is clean\n", ui.RenderPass("✓")) return } - fmt.Printf("Found %d orphaned reference(s):\n", stats.total()) - if stats.orphanedDepsIssueID > 0 { - fmt.Printf(" • %d dependencies with missing issue_id\n", stats.orphanedDepsIssueID) - for _, dep := range orphanedIssueID { - fmt.Printf(" - %s → %s\n", dep.issueID, dep.dependsOnID) + // Print findings (text mode) + if !repairJSON { + fmt.Printf("Found %d orphaned reference(s):\n", stats.total()) + if stats.orphanedDepsIssueID > 0 { + fmt.Printf(" • %d dependencies with missing issue_id\n", stats.orphanedDepsIssueID) + for _, dep := range orphanedIssueID { + fmt.Printf(" - %s → %s\n", dep.issueID, dep.dependsOnID) + } } - } - if stats.orphanedDepsDependsOn > 0 { - fmt.Printf(" • %d dependencies with missing depends_on_id\n", stats.orphanedDepsDependsOn) - for _, dep := range orphanedDependsOn { - fmt.Printf(" - %s → %s\n", dep.issueID, dep.dependsOnID) + if stats.orphanedDepsDependsOn > 0 { + fmt.Printf(" • %d dependencies with missing depends_on_id\n", stats.orphanedDepsDependsOn) + for _, dep := range orphanedDependsOn { + fmt.Printf(" - %s → %s\n", dep.issueID, dep.dependsOnID) + } } - } - if stats.orphanedLabels > 0 { - fmt.Printf(" • %d labels with missing issue_id\n", stats.orphanedLabels) - for _, label := range orphanedLabels { - fmt.Printf(" - %s: %s\n", label.issueID, label.label) + if stats.orphanedLabels > 0 { + fmt.Printf(" • %d labels with missing issue_id\n", stats.orphanedLabels) + for _, label := range orphanedLabels { + fmt.Printf(" - %s: %s\n", label.issueID, label.label) + } } + if stats.orphanedComments > 0 { + fmt.Printf(" • %d comments with missing issue_id\n", stats.orphanedComments) + for _, comment := range orphanedCommentsList { + fmt.Printf(" - %s (by %s)\n", comment.issueID, comment.author) + } + } + if stats.orphanedEvents > 0 { + fmt.Printf(" • %d events with missing issue_id\n", stats.orphanedEvents) + for _, event := range orphanedEventsList { + fmt.Printf(" - %s: %s\n", event.issueID, event.eventType) + } + } + fmt.Println() } - fmt.Println() + // Handle dry-run if repairDryRun { + if repairJSON { + jsonResult.Status = "dry_run" + outputJSONAndExit(jsonResult, 0) + } fmt.Printf("[DRY-RUN] Would delete %d orphaned reference(s)\n", stats.total()) return } // Create backup before destructive operations backupPath := dbPath + ".pre-repair" - fmt.Printf("Creating backup: %s\n", filepath.Base(backupPath)) + if !repairJSON { + fmt.Printf("Creating backup: %s\n", filepath.Base(backupPath)) + } if err := copyFile(dbPath, backupPath); err != nil { + if repairJSON { + jsonResult.Status = "error" + jsonResult.Error = fmt.Sprintf("creating backup: %v", err) + outputJSONAndExit(jsonResult, 1) + } fmt.Fprintf(os.Stderr, "Error creating backup: %v\n", err) fmt.Fprintf(os.Stderr, "Aborting repair. Fix backup issue and retry.\n") os.Exit(1) } - fmt.Printf(" %s Backup created\n\n", ui.RenderPass("✓")) + jsonResult.BackupPath = backupPath + if !repairJSON { + fmt.Printf(" %s Backup created\n\n", ui.RenderPass("✓")) + } // Apply repairs in a transaction - fmt.Println("Cleaning orphaned references...") + if !repairJSON { + fmt.Println("Cleaning orphaned references...") + } tx, err := db.Begin() if err != nil { + if repairJSON { + jsonResult.Status = "error" + jsonResult.Error = fmt.Sprintf("starting transaction: %v", err) + outputJSONAndExit(jsonResult, 1) + } fmt.Fprintf(os.Stderr, "Error starting transaction: %v\n", err) os.Exit(1) } @@ -171,7 +302,7 @@ func runRepair(cmd *cobra.Command, args []string) { `) if err != nil { repairErr = fmt.Errorf("deleting orphaned deps (issue_id): %w", err) - } else { + } else if !repairJSON { deleted, _ := result.RowsAffected() fmt.Printf(" %s Deleted %d dependencies with missing issue_id\n", ui.RenderPass("✓"), deleted) } @@ -191,7 +322,7 @@ func runRepair(cmd *cobra.Command, args []string) { `) if err != nil { repairErr = fmt.Errorf("deleting orphaned deps (depends_on_id): %w", err) - } else { + } else if !repairJSON { deleted, _ := result.RowsAffected() fmt.Printf(" %s Deleted %d dependencies with missing depends_on_id\n", ui.RenderPass("✓"), deleted) } @@ -206,15 +337,50 @@ func runRepair(cmd *cobra.Command, args []string) { `) if err != nil { repairErr = fmt.Errorf("deleting orphaned labels: %w", err) - } else { + } else if !repairJSON { deleted, _ := result.RowsAffected() fmt.Printf(" %s Deleted %d labels with missing issue_id\n", ui.RenderPass("✓"), deleted) } } + // Delete orphaned comments + if len(orphanedCommentsList) > 0 && repairErr == nil { + // Comments reference non-existent issues, so no dirty marking needed + result, err := tx.Exec(` + DELETE FROM comments + WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = comments.issue_id) + `) + if err != nil { + repairErr = fmt.Errorf("deleting orphaned comments: %w", err) + } else if !repairJSON { + deleted, _ := result.RowsAffected() + fmt.Printf(" %s Deleted %d comments with missing issue_id\n", ui.RenderPass("✓"), deleted) + } + } + + // Delete orphaned events + if len(orphanedEventsList) > 0 && repairErr == nil { + // Events reference non-existent issues, so no dirty marking needed + result, err := tx.Exec(` + DELETE FROM events + WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = events.issue_id) + `) + if err != nil { + repairErr = fmt.Errorf("deleting orphaned events: %w", err) + } else if !repairJSON { + deleted, _ := result.RowsAffected() + fmt.Printf(" %s Deleted %d events with missing issue_id\n", ui.RenderPass("✓"), deleted) + } + } + // Commit or rollback if repairErr != nil { _ = tx.Rollback() + if repairJSON { + jsonResult.Status = "error" + jsonResult.Error = repairErr.Error() + outputJSONAndExit(jsonResult, 1) + } fmt.Fprintf(os.Stderr, "\n%s Error: %v\n", ui.RenderFail("✗"), repairErr) fmt.Fprintf(os.Stderr, "Transaction rolled back. Database unchanged.\n") fmt.Fprintf(os.Stderr, "Backup available at: %s\n", backupPath) @@ -222,19 +388,34 @@ func runRepair(cmd *cobra.Command, args []string) { } if err := tx.Commit(); err != nil { + if repairJSON { + jsonResult.Status = "error" + jsonResult.Error = fmt.Sprintf("committing transaction: %v", err) + outputJSONAndExit(jsonResult, 1) + } fmt.Fprintf(os.Stderr, "\n%s Error committing transaction: %v\n", ui.RenderFail("✗"), err) fmt.Fprintf(os.Stderr, "Backup available at: %s\n", backupPath) os.Exit(1) } // Run WAL checkpoint to persist changes - fmt.Print(" Running WAL checkpoint... ") + if !repairJSON { + fmt.Print(" Running WAL checkpoint... ") + } if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { - fmt.Printf("%s %v\n", ui.RenderFail("✗"), err) - } else { + if !repairJSON { + fmt.Printf("%s %v\n", ui.RenderFail("✗"), err) + } + } else if !repairJSON { fmt.Printf("%s\n", ui.RenderPass("✓")) } + // Final output + if repairJSON { + jsonResult.Status = "success" + outputJSONAndExit(jsonResult, 0) + } + fmt.Println() fmt.Printf("%s Repair complete. Try running 'bd doctor' to verify.\n", ui.RenderPass("✓")) fmt.Printf("Backup preserved at: %s\n", filepath.Base(backupPath)) @@ -245,10 +426,12 @@ type repairStats struct { orphanedDepsIssueID int orphanedDepsDependsOn int orphanedLabels int + orphanedComments int + orphanedEvents int } func (s repairStats) total() int { - return s.orphanedDepsIssueID + s.orphanedDepsDependsOn + s.orphanedLabels + return s.orphanedDepsIssueID + s.orphanedDepsDependsOn + s.orphanedLabels + s.orphanedComments + s.orphanedEvents } // orphanedDep represents an orphaned dependency @@ -263,6 +446,70 @@ type orphanedLabel struct { label string } +// orphanedComment represents an orphaned comment +type orphanedComment struct { + id int + issueID string + author string +} + +// orphanedEvent represents an orphaned event +type orphanedEvent struct { + id int + issueID string + eventType string +} + +// repairResult is the JSON output structure +type repairResult struct { + DatabasePath string `json:"database_path"` + DryRun bool `json:"dry_run"` + OrphanCounts repairOrphanCounts `json:"orphan_counts"` + OrphanDetails repairOrphanDetails `json:"orphan_details"` + Status string `json:"status"` // "success", "no_orphans", "dry_run", "error" + BackupPath string `json:"backup_path,omitempty"` + Error string `json:"error,omitempty"` +} + +type repairOrphanCounts struct { + DependenciesIssueID int `json:"dependencies_issue_id"` + DependenciesDependsOn int `json:"dependencies_depends_on"` + Labels int `json:"labels"` + Comments int `json:"comments"` + Events int `json:"events"` + Total int `json:"total"` +} + +type repairOrphanDetails struct { + DependenciesIssueID []repairDepDetail `json:"dependencies_issue_id,omitempty"` + DependenciesDependsOn []repairDepDetail `json:"dependencies_depends_on,omitempty"` + Labels []repairLabelDetail `json:"labels,omitempty"` + Comments []repairCommentDetail `json:"comments,omitempty"` + Events []repairEventDetail `json:"events,omitempty"` +} + +type repairDepDetail struct { + IssueID string `json:"issue_id"` + DependsOnID string `json:"depends_on_id"` +} + +type repairLabelDetail struct { + IssueID string `json:"issue_id"` + Label string `json:"label"` +} + +type repairCommentDetail struct { + ID int `json:"id"` + IssueID string `json:"issue_id"` + Author string `json:"author"` +} + +type repairEventDetail struct { + ID int `json:"id"` + IssueID string `json:"issue_id"` + EventType string `json:"event_type"` +} + // openRepairDB opens SQLite directly for repair, bypassing all beads layer code func openRepairDB(dbPath string) (*sql.DB, error) { // Build connection string with pragmas @@ -349,3 +596,49 @@ func findOrphanedLabels(db *sql.DB) ([]orphanedLabel, error) { return labels, rows.Err() } +// findOrphanedComments finds comments where issue_id doesn't exist +func findOrphanedComments(db *sql.DB) ([]orphanedComment, error) { + rows, err := db.Query(` + SELECT c.id, c.issue_id, c.author + FROM comments c + WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = c.issue_id) + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var comments []orphanedComment + for rows.Next() { + var comment orphanedComment + if err := rows.Scan(&comment.id, &comment.issueID, &comment.author); err != nil { + return nil, err + } + comments = append(comments, comment) + } + return comments, rows.Err() +} + +// findOrphanedEvents finds events where issue_id doesn't exist +func findOrphanedEvents(db *sql.DB) ([]orphanedEvent, error) { + rows, err := db.Query(` + SELECT e.id, e.issue_id, e.event_type + FROM events e + WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = e.issue_id) + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var events []orphanedEvent + for rows.Next() { + var event orphanedEvent + if err := rows.Scan(&event.id, &event.issueID, &event.eventType); err != nil { + return nil, err + } + events = append(events, event) + } + return events, rows.Err() +} +