Add repair commands: bd repair-deps, bd detect-pollution, bd validate
Implements Phase 1 of bd-56 (Repair Commands & AI-Assisted Tooling): New commands: - bd repair-deps: Find and fix orphaned dependency references - bd detect-pollution: Detect test issues using pattern matching - bd validate: Comprehensive health check (orphans, duplicates, pollution) Features: - JSON output support for all commands - Safe deletion with backup for detect-pollution - Auto-fix support for orphaned dependencies - Direct storage access (requires BEADS_NO_DAEMON=1) Closes bd-56 (Phase 1 complete) Related: bd-103, bd-105, bd-106 Amp-Thread-ID: https://ampcode.com/threads/T-5822c6d2-d645-4043-9a8d-3c51ac93bbb7 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
270
cmd/bd/detect_pollution.go
Normal file
270
cmd/bd/detect_pollution.go
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/beads/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var detectPollutionCmd = &cobra.Command{
|
||||||
|
Use: "detect-pollution",
|
||||||
|
Short: "Detect test issues that leaked into production database",
|
||||||
|
Long: `Detect test issues using pattern matching:
|
||||||
|
- Titles starting with 'test', 'benchmark', 'sample', 'tmp', 'temp'
|
||||||
|
- Sequential numbering (test-1, test-2, ...)
|
||||||
|
- Generic descriptions or no description
|
||||||
|
- Created in rapid succession
|
||||||
|
|
||||||
|
Example:
|
||||||
|
bd detect-pollution # Show potential test issues
|
||||||
|
bd detect-pollution --clean # Delete test issues (with confirmation)
|
||||||
|
bd detect-pollution --clean --yes # Delete without confirmation
|
||||||
|
bd detect-pollution --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: detect-pollution command not yet supported in daemon mode\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Use: bd --no-daemon detect-pollution\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
clean, _ := cmd.Flags().GetBool("clean")
|
||||||
|
yes, _ := cmd.Flags().GetBool("yes")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Get all issues
|
||||||
|
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error fetching issues: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect pollution
|
||||||
|
polluted := detectTestPollution(allIssues)
|
||||||
|
|
||||||
|
if len(polluted) == 0 {
|
||||||
|
if !jsonOutput {
|
||||||
|
fmt.Println("No test pollution detected!")
|
||||||
|
} else {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"polluted_count": 0,
|
||||||
|
"issues": []interface{}{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categorize by confidence
|
||||||
|
highConfidence := []pollutionResult{}
|
||||||
|
mediumConfidence := []pollutionResult{}
|
||||||
|
|
||||||
|
for _, p := range polluted {
|
||||||
|
if p.score >= 0.9 {
|
||||||
|
highConfidence = append(highConfidence, p)
|
||||||
|
} else {
|
||||||
|
mediumConfidence = append(mediumConfidence, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"polluted_count": len(polluted),
|
||||||
|
"high_confidence": len(highConfidence),
|
||||||
|
"medium_confidence": len(mediumConfidence),
|
||||||
|
"issues": []map[string]interface{}{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range polluted {
|
||||||
|
result["issues"] = append(result["issues"].([]map[string]interface{}), map[string]interface{}{
|
||||||
|
"id": p.issue.ID,
|
||||||
|
"title": p.issue.Title,
|
||||||
|
"score": p.score,
|
||||||
|
"reasons": p.reasons,
|
||||||
|
"created_at": p.issue.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
outputJSON(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable output
|
||||||
|
fmt.Printf("Found %d potential test issues:\n\n", len(polluted))
|
||||||
|
|
||||||
|
if len(highConfidence) > 0 {
|
||||||
|
fmt.Printf("High Confidence (score ≥ 0.9):\n")
|
||||||
|
for _, p := range highConfidence {
|
||||||
|
fmt.Printf(" %s: %q (score: %.2f)\n", p.issue.ID, p.issue.Title, p.score)
|
||||||
|
for _, reason := range p.reasons {
|
||||||
|
fmt.Printf(" - %s\n", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf(" (Total: %d issues)\n\n", len(highConfidence))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mediumConfidence) > 0 {
|
||||||
|
fmt.Printf("Medium Confidence (score 0.7-0.9):\n")
|
||||||
|
for _, p := range mediumConfidence {
|
||||||
|
fmt.Printf(" %s: %q (score: %.2f)\n", p.issue.ID, p.issue.Title, p.score)
|
||||||
|
for _, reason := range p.reasons {
|
||||||
|
fmt.Printf(" - %s\n", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf(" (Total: %d issues)\n\n", len(mediumConfidence))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !clean {
|
||||||
|
fmt.Printf("Run 'bd detect-pollution --clean' to delete these issues (with confirmation).\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmation prompt
|
||||||
|
if !yes {
|
||||||
|
fmt.Printf("\nDelete %d test issues? [y/N] ", len(polluted))
|
||||||
|
var response string
|
||||||
|
fmt.Scanln(&response)
|
||||||
|
if strings.ToLower(response) != "y" {
|
||||||
|
fmt.Println("Cancelled.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup to JSONL before deleting
|
||||||
|
backupPath := ".beads/pollution-backup.jsonl"
|
||||||
|
if err := backupPollutedIssues(polluted, backupPath); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error backing up issues: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Backed up %d issues to %s\n", len(polluted), backupPath)
|
||||||
|
|
||||||
|
// Delete issues
|
||||||
|
fmt.Printf("\nDeleting %d issues...\n", len(polluted))
|
||||||
|
deleted := 0
|
||||||
|
for _, p := range polluted {
|
||||||
|
if err := deleteIssue(ctx, p.issue.ID); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error deleting %s: %v\n", p.issue.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule auto-flush
|
||||||
|
markDirtyAndScheduleFlush()
|
||||||
|
|
||||||
|
green := color.New(color.FgGreen).SprintFunc()
|
||||||
|
fmt.Printf("%s Deleted %d test issues\n", green("✓"), deleted)
|
||||||
|
fmt.Printf("\nCleanup complete. To restore, run: bd import %s\n", backupPath)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type pollutionResult struct {
|
||||||
|
issue *types.Issue
|
||||||
|
score float64
|
||||||
|
reasons []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectTestPollution(issues []*types.Issue) []pollutionResult {
|
||||||
|
var results []pollutionResult
|
||||||
|
|
||||||
|
// Patterns for test issue titles
|
||||||
|
testPrefixPattern := regexp.MustCompile(`^(test|benchmark|sample|tmp|temp|debug|dummy)[-_\s]`)
|
||||||
|
sequentialPattern := regexp.MustCompile(`^[a-z]+-\d+$`)
|
||||||
|
|
||||||
|
// Group issues by creation time to detect rapid succession
|
||||||
|
issuesByMinute := make(map[int64][]*types.Issue)
|
||||||
|
for _, issue := range issues {
|
||||||
|
minute := issue.CreatedAt.Unix() / 60
|
||||||
|
issuesByMinute[minute] = append(issuesByMinute[minute], issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, issue := range issues {
|
||||||
|
score := 0.0
|
||||||
|
var reasons []string
|
||||||
|
|
||||||
|
title := strings.ToLower(issue.Title)
|
||||||
|
|
||||||
|
// Check for test prefixes (strong signal)
|
||||||
|
if testPrefixPattern.MatchString(title) {
|
||||||
|
score += 0.7
|
||||||
|
reasons = append(reasons, "Title starts with test prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for sequential numbering (medium signal)
|
||||||
|
if sequentialPattern.MatchString(issue.ID) && len(issue.Description) < 20 {
|
||||||
|
score += 0.4
|
||||||
|
reasons = append(reasons, "Sequential ID with minimal description")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for generic/empty description (weak signal)
|
||||||
|
if len(strings.TrimSpace(issue.Description)) == 0 {
|
||||||
|
score += 0.2
|
||||||
|
reasons = append(reasons, "No description")
|
||||||
|
} else if len(issue.Description) < 20 {
|
||||||
|
score += 0.1
|
||||||
|
reasons = append(reasons, "Very short description")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for rapid creation (created with many others in same minute)
|
||||||
|
minute := issue.CreatedAt.Unix() / 60
|
||||||
|
if len(issuesByMinute[minute]) >= 10 {
|
||||||
|
score += 0.3
|
||||||
|
reasons = append(reasons, fmt.Sprintf("Created with %d other issues in same minute", len(issuesByMinute[minute])-1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for generic test titles
|
||||||
|
if strings.Contains(title, "issue for testing") ||
|
||||||
|
strings.Contains(title, "test issue") ||
|
||||||
|
strings.Contains(title, "sample issue") {
|
||||||
|
score += 0.5
|
||||||
|
reasons = append(reasons, "Generic test title")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only include if score is above threshold
|
||||||
|
if score >= 0.7 {
|
||||||
|
results = append(results, pollutionResult{
|
||||||
|
issue: issue,
|
||||||
|
score: score,
|
||||||
|
reasons: reasons,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupPollutedIssues(polluted []pollutionResult, path string) error {
|
||||||
|
// Create backup file
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create backup file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Write each issue as JSONL
|
||||||
|
for _, p := range polluted {
|
||||||
|
data, err := json.Marshal(p.issue)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal issue %s: %w", p.issue.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := file.WriteString(string(data) + "\n"); err != nil {
|
||||||
|
return fmt.Errorf("failed to write issue %s: %w", p.issue.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
detectPollutionCmd.Flags().Bool("clean", false, "Delete detected test issues")
|
||||||
|
detectPollutionCmd.Flags().Bool("yes", false, "Skip confirmation prompt")
|
||||||
|
rootCmd.AddCommand(detectPollutionCmd)
|
||||||
|
}
|
||||||
162
cmd/bd/repair_deps.go
Normal file
162
cmd/bd/repair_deps.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fix, _ := cmd.Flags().GetBool("fix")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Get all issues
|
||||||
|
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error fetching issues: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build ID existence map
|
||||||
|
existingIDs := make(map[string]bool)
|
||||||
|
for _, issue := range allIssues {
|
||||||
|
existingIDs[issue.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find orphaned dependencies
|
||||||
|
type orphanedDep struct {
|
||||||
|
IssueID string
|
||||||
|
OrphanedID string
|
||||||
|
DepType string
|
||||||
|
}
|
||||||
|
|
||||||
|
var orphaned []orphanedDep
|
||||||
|
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output results
|
||||||
|
if jsonOutput {
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"orphaned_count": len(orphaned),
|
||||||
|
"fixed": fix,
|
||||||
|
"orphaned_deps": []map[string]interface{}{},
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
outputJSON(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable output
|
||||||
|
if len(orphaned) == 0 {
|
||||||
|
fmt.Println("No orphaned dependencies found!")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ Removed %s from %s dependencies\n", orphanedID, issueID)
|
||||||
|
fixed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule auto-flush
|
||||||
|
markDirtyAndScheduleFlush()
|
||||||
|
|
||||||
|
fmt.Printf("\nRepaired %d orphaned dependencies.\n", fixed)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
repairDepsCmd.Flags().Bool("fix", false, "Remove orphaned dependency references")
|
||||||
|
rootCmd.AddCommand(repairDepsCmd)
|
||||||
|
}
|
||||||
312
cmd/bd/validate.go
Normal file
312
cmd/bd/validate.go
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/beads/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var validateCmd = &cobra.Command{
|
||||||
|
Use: "validate",
|
||||||
|
Short: "Run comprehensive database health checks",
|
||||||
|
Long: `Run all validation checks to ensure database integrity:
|
||||||
|
- Orphaned dependencies (references to deleted issues)
|
||||||
|
- Duplicate issues (identical content)
|
||||||
|
- Test pollution (leaked test issues)
|
||||||
|
- Git merge conflicts in JSONL
|
||||||
|
|
||||||
|
Example:
|
||||||
|
bd validate # Run all checks
|
||||||
|
bd validate --fix-all # Auto-fix all issues
|
||||||
|
bd validate --checks=orphans,dupes # Run specific checks
|
||||||
|
bd validate --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: validate command not yet supported in daemon mode\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Use: bd --no-daemon validate\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fixAll, _ := cmd.Flags().GetBool("fix-all")
|
||||||
|
checksFlag, _ := cmd.Flags().GetString("checks")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Determine which checks to run
|
||||||
|
var checks []string
|
||||||
|
if checksFlag == "" {
|
||||||
|
checks = []string{"orphans", "duplicates", "pollution"}
|
||||||
|
} else {
|
||||||
|
checks = strings.Split(checksFlag, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
results := validationResults{
|
||||||
|
checks: make(map[string]checkResult),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run each check
|
||||||
|
for _, check := range checks {
|
||||||
|
switch check {
|
||||||
|
case "orphans":
|
||||||
|
results.checks["orphans"] = validateOrphanedDeps(ctx, fixAll)
|
||||||
|
case "duplicates", "dupes":
|
||||||
|
results.checks["duplicates"] = validateDuplicates(ctx, fixAll)
|
||||||
|
case "pollution":
|
||||||
|
results.checks["pollution"] = validatePollution(ctx, fixAll)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "Unknown check: %s\n", check)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output results
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(results.toJSON())
|
||||||
|
} else {
|
||||||
|
results.print(fixAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit with error code if issues found
|
||||||
|
if results.hasIssues() {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type checkResult struct {
|
||||||
|
name string
|
||||||
|
issueCount int
|
||||||
|
fixedCount int
|
||||||
|
err error
|
||||||
|
suggestions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type validationResults struct {
|
||||||
|
checks map[string]checkResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *validationResults) hasIssues() bool {
|
||||||
|
for _, result := range r.checks {
|
||||||
|
if result.issueCount > 0 && result.fixedCount < result.issueCount {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *validationResults) toJSON() map[string]interface{} {
|
||||||
|
output := map[string]interface{}{
|
||||||
|
"checks": map[string]interface{}{},
|
||||||
|
}
|
||||||
|
|
||||||
|
totalIssues := 0
|
||||||
|
totalFixed := 0
|
||||||
|
|
||||||
|
for name, result := range r.checks {
|
||||||
|
output["checks"].(map[string]interface{})[name] = map[string]interface{}{
|
||||||
|
"issue_count": result.issueCount,
|
||||||
|
"fixed_count": result.fixedCount,
|
||||||
|
"error": result.err,
|
||||||
|
"suggestions": result.suggestions,
|
||||||
|
}
|
||||||
|
totalIssues += result.issueCount
|
||||||
|
totalFixed += result.fixedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
output["total_issues"] = totalIssues
|
||||||
|
output["total_fixed"] = totalFixed
|
||||||
|
output["healthy"] = totalIssues == 0 || totalIssues == totalFixed
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *validationResults) print(fixAll bool) {
|
||||||
|
green := color.New(color.FgGreen).SprintFunc()
|
||||||
|
yellow := color.New(color.FgYellow).SprintFunc()
|
||||||
|
red := color.New(color.FgRed).SprintFunc()
|
||||||
|
|
||||||
|
fmt.Println("\nValidation Results:")
|
||||||
|
fmt.Println("===================")
|
||||||
|
|
||||||
|
totalIssues := 0
|
||||||
|
totalFixed := 0
|
||||||
|
|
||||||
|
for name, result := range r.checks {
|
||||||
|
prefix := "✓"
|
||||||
|
colorFunc := green
|
||||||
|
|
||||||
|
if result.err != nil {
|
||||||
|
prefix = "✗"
|
||||||
|
colorFunc = red
|
||||||
|
fmt.Printf("%s %s: ERROR - %v\n", colorFunc(prefix), name, result.err)
|
||||||
|
} else if result.issueCount > 0 {
|
||||||
|
prefix = "⚠"
|
||||||
|
colorFunc = yellow
|
||||||
|
if result.fixedCount > 0 {
|
||||||
|
fmt.Printf("%s %s: %d found, %d fixed\n", colorFunc(prefix), name, result.issueCount, result.fixedCount)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s %s: %d found\n", colorFunc(prefix), name, result.issueCount)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s %s: OK\n", colorFunc(prefix), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalIssues += result.issueCount
|
||||||
|
totalFixed += result.fixedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if totalIssues == 0 {
|
||||||
|
fmt.Printf("%s Database is healthy!\n", green("✓"))
|
||||||
|
} else if totalFixed == totalIssues {
|
||||||
|
fmt.Printf("%s Fixed all %d issues\n", green("✓"), totalFixed)
|
||||||
|
} else {
|
||||||
|
remaining := totalIssues - totalFixed
|
||||||
|
fmt.Printf("%s Found %d issues", yellow("⚠"), totalIssues)
|
||||||
|
if totalFixed > 0 {
|
||||||
|
fmt.Printf(" (fixed %d, %d remaining)", totalFixed, remaining)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Print suggestions
|
||||||
|
fmt.Println("\nRecommendations:")
|
||||||
|
for _, result := range r.checks {
|
||||||
|
for _, suggestion := range result.suggestions {
|
||||||
|
fmt.Printf(" - %s\n", suggestion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOrphanedDeps(ctx context.Context, fix bool) checkResult {
|
||||||
|
result := checkResult{name: "orphaned dependencies"}
|
||||||
|
|
||||||
|
// Get all issues
|
||||||
|
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||||
|
if err != nil {
|
||||||
|
result.err = err
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build ID existence map
|
||||||
|
existingIDs := make(map[string]bool)
|
||||||
|
for _, issue := range allIssues {
|
||||||
|
existingIDs[issue.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find orphaned dependencies
|
||||||
|
type orphanedDep struct {
|
||||||
|
issueID string
|
||||||
|
orphanedID string
|
||||||
|
}
|
||||||
|
var orphaned []orphanedDep
|
||||||
|
|
||||||
|
for _, issue := range allIssues {
|
||||||
|
for _, dep := range issue.Dependencies {
|
||||||
|
if !existingIDs[dep.DependsOnID] {
|
||||||
|
orphaned = append(orphaned, orphanedDep{
|
||||||
|
issueID: issue.ID,
|
||||||
|
orphanedID: dep.DependsOnID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.issueCount = len(orphaned)
|
||||||
|
|
||||||
|
if fix && len(orphaned) > 0 {
|
||||||
|
// Group by issue
|
||||||
|
orphansByIssue := make(map[string][]string)
|
||||||
|
for _, o := range orphaned {
|
||||||
|
orphansByIssue[o.issueID] = append(orphansByIssue[o.issueID], o.orphanedID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix each issue
|
||||||
|
for issueID, orphanedIDs := range orphansByIssue {
|
||||||
|
for _, orphanedID := range orphanedIDs {
|
||||||
|
if err := store.RemoveDependency(ctx, issueID, orphanedID, actor); err == nil {
|
||||||
|
result.fixedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.fixedCount > 0 {
|
||||||
|
markDirtyAndScheduleFlush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.issueCount > result.fixedCount {
|
||||||
|
result.suggestions = append(result.suggestions, "Run 'bd repair-deps --fix' to remove orphaned dependencies")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDuplicates(ctx context.Context, fix bool) checkResult {
|
||||||
|
result := checkResult{name: "duplicates"}
|
||||||
|
|
||||||
|
// Get all issues
|
||||||
|
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||||
|
if err != nil {
|
||||||
|
result.err = err
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find duplicates
|
||||||
|
duplicateGroups := findDuplicateGroups(allIssues)
|
||||||
|
|
||||||
|
// Count total duplicate issues (excluding one canonical per group)
|
||||||
|
for _, group := range duplicateGroups {
|
||||||
|
result.issueCount += len(group) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if fix && len(duplicateGroups) > 0 {
|
||||||
|
// Note: Auto-merge is complex and requires user review
|
||||||
|
// We don't auto-fix duplicates, just report them
|
||||||
|
result.suggestions = append(result.suggestions,
|
||||||
|
fmt.Sprintf("Run 'bd duplicates --auto-merge' to merge %d duplicate groups", len(duplicateGroups)))
|
||||||
|
} else if result.issueCount > 0 {
|
||||||
|
result.suggestions = append(result.suggestions,
|
||||||
|
fmt.Sprintf("Run 'bd duplicates' to review %d duplicate groups", len(duplicateGroups)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePollution(ctx context.Context, fix bool) checkResult {
|
||||||
|
result := checkResult{name: "test pollution"}
|
||||||
|
|
||||||
|
// Get all issues
|
||||||
|
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||||
|
if err != nil {
|
||||||
|
result.err = err
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect pollution
|
||||||
|
polluted := detectTestPollution(allIssues)
|
||||||
|
result.issueCount = len(polluted)
|
||||||
|
|
||||||
|
if fix && len(polluted) > 0 {
|
||||||
|
// Note: Deleting issues is destructive, we just suggest it
|
||||||
|
result.suggestions = append(result.suggestions,
|
||||||
|
fmt.Sprintf("Run 'bd detect-pollution --clean' to delete %d test issues", len(polluted)))
|
||||||
|
} else if result.issueCount > 0 {
|
||||||
|
result.suggestions = append(result.suggestions,
|
||||||
|
fmt.Sprintf("Run 'bd detect-pollution' to review %d potential test issues", len(polluted)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
validateCmd.Flags().Bool("fix-all", false, "Auto-fix all fixable issues")
|
||||||
|
validateCmd.Flags().String("checks", "", "Comma-separated list of checks (orphans,duplicates,pollution)")
|
||||||
|
rootCmd.AddCommand(validateCmd)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user