Add rename-prefix --repair flag and consolidate issue ID parsing

Enhances rename-prefix command with --repair flag to consolidate databases
with multiple prefixes. Creates shared issue ID utilities to eliminate code
duplication across import and rename operations.

Key changes:
- Add --repair flag to detect and consolidate multiple issue prefixes
- Create internal/utils/issue_id.go with ExtractIssuePrefix() and ExtractIssueNumber()
- Update all duplicate prefix extraction code to use shared utilities
- Add comprehensive tests for repair functionality

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
Ryan Newton + Claude
2025-10-27 18:31:01 +00:00
parent 969f3ac03b
commit e3df9cfa97
7 changed files with 364 additions and 21 deletions

View File

@@ -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)

View File

@@ -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)
}
}
}

View File

@@ -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))

View File

@@ -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+"-")

View File

@@ -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)
}

View File

@@ -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)")
}
}

View File

@@ -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
}