fix(rename-prefix): use hash IDs instead of sequential in --repair mode

The --repair flag was generating sequential IDs (sageox-9895, sageox-9896)
instead of hash-based IDs (sageox-jwnv, sageox-urtm). This fix uses the
proper GenerateIssueID function from sqlite package to generate consistent
hash-based IDs during prefix repair operations.

Changes:
- Import sqlite package for hash ID generation
- Add generateRepairHashID helper that uses sqlite.GenerateIssueID
- Track used IDs within batch to avoid collisions
- Update test to verify hash IDs instead of sequential
This commit is contained in:
Ryan Snodgrass
2025-12-15 16:10:50 -08:00
parent 9544558840
commit fa566a9700
2 changed files with 90 additions and 37 deletions

View File

@@ -2,16 +2,19 @@ package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"regexp"
"sort"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"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"
)
@@ -225,7 +228,7 @@ type issueSort struct {
// 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.
// Issues with incorrect prefixes get new hash-based IDs.
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()
@@ -235,16 +238,12 @@ func repairPrefixes(ctx context.Context, st storage.Storage, actorName string, t
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,
@@ -262,43 +261,58 @@ func repairPrefixes(ctx context.Context, st storage.Storage, actorName string, t
return incorrectIssues[i].number < incorrectIssues[j].number
})
// Get a database connection for ID generation
conn, err := st.UnderlyingConn(ctx)
if err != nil {
return fmt.Errorf("failed to get database connection: %w", err)
}
defer func() { _ = conn.Close() }()
// Build a map of all renames for text replacement using hash IDs
// Track used IDs to avoid collisions within the batch
renameMap := make(map[string]string)
usedIDs := make(map[string]bool)
// Mark existing correct IDs as used
for _, issue := range correctIssues {
usedIDs[issue.ID] = true
}
// Generate hash IDs for all incorrect issues
for _, is := range incorrectIssues {
newID, err := generateRepairHashID(ctx, conn, targetPrefix, is.issue, actorName, usedIDs)
if err != nil {
return fmt.Errorf("failed to generate hash ID for %s: %w", is.issue.ID, err)
}
renameMap[is.issue.ID] = newID
usedIDs[newID] = true
}
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 with correct prefix (%s): %d\n", cyan(targetPrefix), len(correctIssues))
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)
newID := renameMap[oldID]
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 with correct prefix (%s): %d\n", cyan(targetPrefix), len(correctIssues))
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++
}
// Pattern to match any issue ID reference in text (both hash and sequential IDs)
oldPrefixPattern := regexp.MustCompile(`\b[a-z][a-z0-9-]*-[a-z0-9]+\b`)
// Rename each issue
for _, is := range incorrectIssues {
@@ -369,11 +383,10 @@ func repairPrefixes(ctx context.Context, st storage.Storage, actorName string, t
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,
"target_prefix": targetPrefix,
"prefixes_found": len(prefixes),
"issues_repaired": len(incorrectIssues),
"issues_unchanged": len(correctIssues),
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
@@ -434,6 +447,37 @@ func renamePrefixInDB(ctx context.Context, oldPrefix, newPrefix string, issues [
return nil
}
// generateRepairHashID generates a hash-based ID for an issue during repair
// Uses the sqlite.GenerateIssueID function but also checks usedIDs for batch collision avoidance
func generateRepairHashID(ctx context.Context, conn *sql.Conn, prefix string, issue *types.Issue, actor string, usedIDs map[string]bool) (string, error) {
// Try to generate a unique ID using the standard generation function
// This handles collision detection against existing database IDs
newID, err := sqlite.GenerateIssueID(ctx, conn, prefix, issue, actor)
if err != nil {
return "", err
}
// Check if this ID was already used in this batch
// If so, we need to generate a new one with a different timestamp
attempts := 0
for usedIDs[newID] && attempts < 100 {
// Slightly modify the creation time to get a different hash
modifiedIssue := *issue
modifiedIssue.CreatedAt = issue.CreatedAt.Add(time.Duration(attempts+1) * time.Nanosecond)
newID, err = sqlite.GenerateIssueID(ctx, conn, prefix, &modifiedIssue, actor)
if err != nil {
return "", err
}
attempts++
}
if usedIDs[newID] {
return "", fmt.Errorf("failed to generate unique ID after %d attempts", attempts)
}
return newID, nil
}
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")

View File

@@ -93,19 +93,28 @@ func TestRepairMultiplePrefixes(t *testing.T) {
}
}
// 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)")
// Verify the others were renamed with hash IDs (not sequential)
// We have 5 total issues, 2 original (test-1, test-2), 3 renamed
if len(allIssues) != 5 {
t.Fatalf("expected 5 issues total, got %d", len(allIssues))
}
issue, err = store.GetIssue(ctx, "test-4")
if err != nil || issue == nil {
t.Fatalf("expected test-4 to exist (renamed from old-1)")
// Count issues with correct prefix and verify old IDs no longer exist
testPrefixCount := 0
for _, issue := range allIssues {
if len(issue.ID) > 5 && issue.ID[:5] == "test-" {
testPrefixCount++
}
}
if testPrefixCount != 5 {
t.Fatalf("expected all 5 issues to have 'test-' prefix, got %d", testPrefixCount)
}
issue, err = store.GetIssue(ctx, "test-5")
if err != nil || issue == nil {
t.Fatalf("expected test-5 to exist (renamed from old-2)")
// Verify old IDs no longer exist
for _, oldID := range []string{"old-1", "old-2", "another-1"} {
issue, err := store.GetIssue(ctx, oldID)
if err == nil && issue != nil {
t.Fatalf("expected old ID %s to no longer exist", oldID)
}
}
}