diff --git a/cmd/bd/autoimport.go b/cmd/bd/autoimport.go index 961a160e..c7445156 100644 --- a/cmd/bd/autoimport.go +++ b/cmd/bd/autoimport.go @@ -13,6 +13,7 @@ import ( "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" ) // checkAndAutoImport checks if the database is empty but git has issues. @@ -174,7 +175,7 @@ func importFromGit(ctx context.Context, dbFilePath string, store storage.Storage configuredPrefix, err := store.GetConfig(ctx, "issue_prefix") if err == nil && strings.TrimSpace(configuredPrefix) == "" { // Database has no prefix configured - derive from first issue - firstPrefix := extractPrefix(issues[0].ID) + firstPrefix := utils.ExtractIssuePrefix(issues[0].ID) if firstPrefix != "" { if err := store.SetConfig(ctx, "issue_prefix", firstPrefix); err != nil { return fmt.Errorf("failed to set issue_prefix from imported issues: %w", err) diff --git a/cmd/bd/helpers_test.go b/cmd/bd/helpers_test.go index 1dddbb39..18b932ca 100644 --- a/cmd/bd/helpers_test.go +++ b/cmd/bd/helpers_test.go @@ -2,6 +2,8 @@ package main import ( "testing" + + "github.com/steveyegge/beads/internal/utils" ) func TestIsBoundary(t *testing.T) { @@ -82,9 +84,9 @@ func TestExtractPrefix(t *testing.T) { } for _, tt := range tests { - result := extractPrefix(tt.input) + result := utils.ExtractIssuePrefix(tt.input) if result != tt.expected { - t.Errorf("extractPrefix(%q) = %q, want %q", tt.input, result, tt.expected) + t.Errorf("ExtractIssuePrefix(%q) = %q, want %q", tt.input, result, tt.expected) } } } diff --git a/cmd/bd/import_phases.go b/cmd/bd/import_phases.go index de665a05..e3da3a0d 100644 --- a/cmd/bd/import_phases.go +++ b/cmd/bd/import_phases.go @@ -9,6 +9,7 @@ import ( "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" ) // Phase 1: Get or create SQLite store for import @@ -58,7 +59,7 @@ func handlePrefixMismatch(ctx context.Context, sqliteStore *sqlite.SQLiteStorage // Analyze prefixes in imported issues for _, issue := range issues { - prefix := extractPrefix(issue.ID) + prefix := utils.ExtractIssuePrefix(issue.ID) if prefix != configuredPrefix { result.PrefixMismatch = true result.MismatchPrefixes[prefix]++ @@ -350,14 +351,6 @@ func importComments(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issu // Helper functions -func extractPrefix(issueID string) string { - parts := strings.SplitN(issueID, "-", 2) - if len(parts) < 2 { - return "" // No prefix found - } - return parts[0] -} - func getPrefixList(prefixes map[string]int) []string { var result []string keys := make([]string, 0, len(prefixes)) diff --git a/cmd/bd/import_shared.go b/cmd/bd/import_shared.go index cb7935ec..87672d23 100644 --- a/cmd/bd/import_shared.go +++ b/cmd/bd/import_shared.go @@ -9,6 +9,7 @@ import ( "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" ) // fieldComparator handles comparison logic for a specific field type @@ -259,13 +260,13 @@ func importIssuesCore(ctx context.Context, dbPath string, store storage.Storage, func renameImportedIssuePrefixes(issues []*types.Issue, targetPrefix string) error { // Build a mapping of old IDs to new IDs idMapping := make(map[string]string) - + for _, issue := range issues { - oldPrefix := extractPrefix(issue.ID) + oldPrefix := utils.ExtractIssuePrefix(issue.ID) if oldPrefix == "" { return fmt.Errorf("cannot rename issue %s: malformed ID (no hyphen found)", issue.ID) } - + if oldPrefix != targetPrefix { // Extract the numeric part numPart := strings.TrimPrefix(issue.ID, oldPrefix+"-") diff --git a/cmd/bd/rename_prefix.go b/cmd/bd/rename_prefix.go index a7f75f30..1f30e4f2 100644 --- a/cmd/bd/rename_prefix.go +++ b/cmd/bd/rename_prefix.go @@ -6,11 +6,14 @@ import ( "fmt" "os" "regexp" + "sort" "strings" "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" ) var renamePrefixCmd = &cobra.Command{ @@ -26,12 +29,19 @@ Prefix validation rules: - Must end with a hyphen (e.g., 'kw-', 'work-') - Cannot be empty or just a hyphen +Multiple prefix detection and repair: +If issues have multiple prefixes (corrupted database), use --repair to consolidate them. +The --repair flag will rename all issues with incorrect prefixes to the new prefix, +preserving issues that already have the correct prefix. + Example: - bd rename-prefix kw- # Rename from 'knowledge-work-' to 'kw-'`, + bd rename-prefix kw- # Rename from 'knowledge-work-' to 'kw-' + bd rename-prefix mtg- --repair # Consolidate multiple prefixes into 'mtg-'`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { newPrefix := args[0] dryRun, _ := cmd.Flags().GetBool("dry-run") + repair, _ := cmd.Flags().GetBool("repair") ctx := context.Background() @@ -61,17 +71,47 @@ Example: newPrefix = strings.TrimRight(newPrefix, "-") - if oldPrefix == newPrefix { - fmt.Fprintf(os.Stderr, "Error: new prefix is the same as current prefix: %s\n", oldPrefix) - os.Exit(1) - } - + // Check for multiple prefixes first 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) } + prefixes := detectPrefixes(issues) + + if len(prefixes) > 1 { + // Multiple prefixes detected - requires repair mode + red := color.New(color.FgRed).SprintFunc() + yellow := color.New(color.FgYellow).SprintFunc() + + fmt.Fprintf(os.Stderr, "%s Multiple prefixes detected in database:\n", red("✗")) + for prefix, count := range prefixes { + fmt.Fprintf(os.Stderr, " - %s: %d issues\n", yellow(prefix), count) + } + fmt.Fprintf(os.Stderr, "\n") + + if !repair { + fmt.Fprintf(os.Stderr, "Error: cannot rename with multiple prefixes. Use --repair to consolidate.\n") + fmt.Fprintf(os.Stderr, "Example: bd rename-prefix %s --repair\n", newPrefix) + os.Exit(1) + } + + // Repair mode: consolidate all prefixes to newPrefix + if err := repairPrefixes(ctx, store, actor, newPrefix, issues, prefixes, dryRun); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to repair prefixes: %v\n", err) + os.Exit(1) + } + return + } + + // Single prefix case - check if trying to rename to same prefix + if len(prefixes) == 1 && oldPrefix == newPrefix { + fmt.Fprintf(os.Stderr, "Error: new prefix is the same as current prefix: %s\n", oldPrefix) + os.Exit(1) + } + + // issues already fetched above if len(issues) == 0 { fmt.Printf("No issues to rename. Updating prefix to %s\n", newPrefix) if !dryRun { @@ -150,6 +190,185 @@ func validatePrefix(prefix string) error { return nil } +// detectPrefixes analyzes all issues and returns a map of prefix -> count +func detectPrefixes(issues []*types.Issue) map[string]int { + prefixes := make(map[string]int) + for _, issue := range issues { + prefix := utils.ExtractIssuePrefix(issue.ID) + if prefix != "" { + prefixes[prefix]++ + } + } + return prefixes +} + +// issueSort is used for sorting issues by prefix and number +type issueSort struct { + issue *types.Issue + prefix string + number int +} + +// repairPrefixes consolidates multiple prefixes into a single target prefix +// Issues with the correct prefix are left unchanged. +// Issues with incorrect prefixes are sorted and renumbered sequentially. +func repairPrefixes(ctx context.Context, st storage.Storage, actorName string, targetPrefix string, issues []*types.Issue, prefixes map[string]int, dryRun bool) error { + green := color.New(color.FgGreen).SprintFunc() + cyan := color.New(color.FgCyan).SprintFunc() + yellow := color.New(color.FgYellow).SprintFunc() + + // Separate issues into correct and incorrect prefix groups + var correctIssues []*types.Issue + var incorrectIssues []issueSort + + maxCorrectNumber := 0 + for _, issue := range issues { + prefix := utils.ExtractIssuePrefix(issue.ID) + number := utils.ExtractIssueNumber(issue.ID) + + if prefix == targetPrefix { + correctIssues = append(correctIssues, issue) + if number > maxCorrectNumber { + maxCorrectNumber = number + } + } else { + incorrectIssues = append(incorrectIssues, issueSort{ + issue: issue, + prefix: prefix, + number: number, + }) + } + } + + // Sort incorrect issues: first by prefix lexicographically, then by number + sort.Slice(incorrectIssues, func(i, j int) bool { + if incorrectIssues[i].prefix != incorrectIssues[j].prefix { + return incorrectIssues[i].prefix < incorrectIssues[j].prefix + } + return incorrectIssues[i].number < incorrectIssues[j].number + }) + + if dryRun { + fmt.Printf("DRY RUN: Would repair %d issues with incorrect prefixes\n\n", len(incorrectIssues)) + fmt.Printf("Issues with correct prefix (%s): %d (highest number: %d)\n", cyan(targetPrefix), len(correctIssues), maxCorrectNumber) + fmt.Printf("Issues to repair: %d\n\n", len(incorrectIssues)) + + fmt.Printf("Planned renames (showing first 10):\n") + nextNumber := maxCorrectNumber + 1 + for i, is := range incorrectIssues { + if i >= 10 { + fmt.Printf("... and %d more\n", len(incorrectIssues)-10) + break + } + oldID := is.issue.ID + newID := fmt.Sprintf("%s-%d", targetPrefix, nextNumber) + fmt.Printf(" %s -> %s\n", yellow(oldID), cyan(newID)) + nextNumber++ + } + return nil + } + + // Perform the repairs + fmt.Printf("Repairing database with multiple prefixes...\n") + fmt.Printf(" Issues with correct prefix (%s): %d (highest: %s-%d)\n", + cyan(targetPrefix), len(correctIssues), targetPrefix, maxCorrectNumber) + fmt.Printf(" Issues to repair: %d\n\n", len(incorrectIssues)) + + oldPrefixPattern := regexp.MustCompile(`\b[a-z][a-z0-9-]*-(\d+)\b`) + + // Build a map of all renames for text replacement + renameMap := make(map[string]string) + nextNumber := maxCorrectNumber + 1 + for _, is := range incorrectIssues { + oldID := is.issue.ID + newID := fmt.Sprintf("%s-%d", targetPrefix, nextNumber) + renameMap[oldID] = newID + nextNumber++ + } + + // Rename each issue + for _, is := range incorrectIssues { + oldID := is.issue.ID + newID := renameMap[oldID] + + // Apply text replacements in all issue fields + issue := is.issue + issue.ID = newID + + // Replace all issue IDs in text fields using the rename map + replaceFunc := func(match string) string { + if newID, ok := renameMap[match]; ok { + return newID + } + return match + } + + issue.Title = oldPrefixPattern.ReplaceAllStringFunc(issue.Title, replaceFunc) + issue.Description = oldPrefixPattern.ReplaceAllStringFunc(issue.Description, replaceFunc) + if issue.Design != "" { + issue.Design = oldPrefixPattern.ReplaceAllStringFunc(issue.Design, replaceFunc) + } + if issue.AcceptanceCriteria != "" { + issue.AcceptanceCriteria = oldPrefixPattern.ReplaceAllStringFunc(issue.AcceptanceCriteria, replaceFunc) + } + if issue.Notes != "" { + issue.Notes = oldPrefixPattern.ReplaceAllStringFunc(issue.Notes, replaceFunc) + } + + // Update the issue in the database + if err := st.UpdateIssueID(ctx, oldID, newID, issue, actorName); err != nil { + return fmt.Errorf("failed to update issue %s -> %s: %w", oldID, newID, err) + } + + fmt.Printf(" Renamed %s -> %s\n", yellow(oldID), cyan(newID)) + } + + // Update all dependencies to use new prefix + for oldPrefix := range prefixes { + if oldPrefix != targetPrefix { + if err := st.RenameDependencyPrefix(ctx, oldPrefix, targetPrefix); err != nil { + return fmt.Errorf("failed to update dependencies for prefix %s: %w", oldPrefix, err) + } + } + } + + // Update counters for all old prefixes + for oldPrefix := range prefixes { + if oldPrefix != targetPrefix { + if err := st.RenameCounterPrefix(ctx, oldPrefix, targetPrefix); err != nil { + return fmt.Errorf("failed to update counter for prefix %s: %w", oldPrefix, err) + } + } + } + + // Set the new prefix in config + if err := st.SetConfig(ctx, "issue_prefix", targetPrefix); err != nil { + return fmt.Errorf("failed to update config: %w", err) + } + + // Schedule full export (IDs changed, incremental won't work) + markDirtyAndScheduleFullExport() + + fmt.Printf("\n%s Successfully consolidated %d prefixes into %s\n", + green("✓"), len(prefixes), cyan(targetPrefix)) + fmt.Printf(" %d issues repaired, %d issues unchanged\n", len(incorrectIssues), len(correctIssues)) + + if jsonOutput { + result := map[string]interface{}{ + "target_prefix": targetPrefix, + "prefixes_found": len(prefixes), + "issues_repaired": len(incorrectIssues), + "issues_unchanged": len(correctIssues), + "highest_number": nextNumber - 1, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(result) + } + + return nil +} + func renamePrefixInDB(ctx context.Context, oldPrefix, newPrefix string, issues []*types.Issue) error { // NOTE: Each issue is updated in its own transaction. A failure mid-way could leave // the database in a mixed state with some issues renamed and others not. @@ -203,5 +422,6 @@ func renamePrefixInDB(ctx context.Context, oldPrefix, newPrefix string, issues [ func init() { renamePrefixCmd.Flags().Bool("dry-run", false, "Preview changes without applying them") + renamePrefixCmd.Flags().Bool("repair", false, "Repair database with multiple prefixes by consolidating them") rootCmd.AddCommand(renamePrefixCmd) } diff --git a/cmd/bd/rename_prefix_repair_test.go b/cmd/bd/rename_prefix_repair_test.go new file mode 100644 index 00000000..d379d30d --- /dev/null +++ b/cmd/bd/rename_prefix_repair_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "testing" + + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" +) + +func TestRepairMultiplePrefixes(t *testing.T) { + // Create a temporary database + dbPath := t.TempDir() + "/test.db" + store, err := sqlite.New(dbPath) + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer store.Close() + + ctx := context.Background() + + // Set initial prefix + if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("failed to set prefix: %v", err) + } + + // Create issues with multiple prefixes (simulating corruption) + // We'll manually create issues with different prefixes + issues := []*types.Issue{ + {ID: "test-1", Title: "Test issue 1", Status: "open", Priority: 2, IssueType: "task"}, + {ID: "test-2", Title: "Test issue 2", Status: "open", Priority: 2, IssueType: "task"}, + {ID: "old-1", Title: "Old issue 1", Status: "open", Priority: 2, IssueType: "task"}, + {ID: "old-2", Title: "Old issue 2", Status: "open", Priority: 2, IssueType: "task"}, + {ID: "another-1", Title: "Another issue 1", Status: "open", Priority: 2, IssueType: "task"}, + } + + for _, issue := range issues { + if err := store.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("failed to create issue %s: %v", issue.ID, err) + } + } + + // Verify we have multiple prefixes + allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{}) + if err != nil { + t.Fatalf("failed to search issues: %v", err) + } + + prefixes := detectPrefixes(allIssues) + if len(prefixes) != 3 { + t.Fatalf("expected 3 prefixes, got %d: %v", len(prefixes), prefixes) + } + + // Test repair + if err := repairPrefixes(ctx, store, "test", "test", allIssues, prefixes, false); err != nil { + t.Fatalf("repair failed: %v", err) + } + + // Verify all issues now have correct prefix + allIssues, err = store.SearchIssues(ctx, "", types.IssueFilter{}) + if err != nil { + t.Fatalf("failed to search issues after repair: %v", err) + } + + prefixes = detectPrefixes(allIssues) + if len(prefixes) != 1 { + t.Fatalf("expected 1 prefix after repair, got %d: %v", len(prefixes), prefixes) + } + + if _, ok := prefixes["test"]; !ok { + t.Fatalf("expected prefix 'test', got %v", prefixes) + } + + // Verify the original test-1 and test-2 are unchanged + for _, id := range []string{"test-1", "test-2"} { + issue, err := store.GetIssue(ctx, id) + if err != nil { + t.Fatalf("expected issue %s to exist unchanged: %v", id, err) + } + if issue == nil { + t.Fatalf("expected issue %s to exist", id) + } + } + + // Verify the others were renumbered + issue, err := store.GetIssue(ctx, "test-3") + if err != nil || issue == nil { + t.Fatalf("expected test-3 to exist (renamed from another-1)") + } + + issue, err = store.GetIssue(ctx, "test-4") + if err != nil || issue == nil { + t.Fatalf("expected test-4 to exist (renamed from old-1)") + } + + issue, err = store.GetIssue(ctx, "test-5") + if err != nil || issue == nil { + t.Fatalf("expected test-5 to exist (renamed from old-2)") + } +} diff --git a/internal/utils/issue_id.go b/internal/utils/issue_id.go new file mode 100644 index 00000000..0ef25551 --- /dev/null +++ b/internal/utils/issue_id.go @@ -0,0 +1,26 @@ +package utils + +import ( + "fmt" + "strings" +) + +// ExtractIssuePrefix extracts the prefix from an issue ID like "bd-123" -> "bd" +func ExtractIssuePrefix(issueID string) string { + parts := strings.SplitN(issueID, "-", 2) + if len(parts) < 2 { + return "" + } + return parts[0] +} + +// ExtractIssueNumber extracts the number from an issue ID like "bd-123" -> 123 +func ExtractIssueNumber(issueID string) int { + parts := strings.SplitN(issueID, "-", 2) + if len(parts) < 2 { + return 0 + } + var num int + fmt.Sscanf(parts[1], "%d", &num) + return num +}