Resolve merge conflicts: use importer package
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -2,6 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/importer"
|
||||
"github.com/steveyegge/beads/internal/utils"
|
||||
)
|
||||
|
||||
func TestIsNumeric(t *testing.T) {
|
||||
@@ -45,9 +48,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +62,7 @@ func TestGetPrefixList(t *testing.T) {
|
||||
"test": 1,
|
||||
}
|
||||
|
||||
result := getPrefixList(prefixMap)
|
||||
result := importer.GetPrefixList(prefixMap)
|
||||
|
||||
// Should have 3 entries
|
||||
if len(result) != 3 {
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/importer"
|
||||
"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
|
||||
@@ -115,29 +115,6 @@ func (fc *fieldComparator) equalPriority(existing int, newVal interface{}) bool
|
||||
return existing == int(p)
|
||||
}
|
||||
|
||||
// equalTime compares *time.Time field
|
||||
func (fc *fieldComparator) equalTime(existing *time.Time, newVal interface{}) bool {
|
||||
switch t := newVal.(type) {
|
||||
case *time.Time:
|
||||
if existing == nil && t == nil {
|
||||
return true
|
||||
}
|
||||
if existing == nil || t == nil {
|
||||
return false
|
||||
}
|
||||
return existing.Equal(*t)
|
||||
case time.Time:
|
||||
if existing == nil {
|
||||
return false
|
||||
}
|
||||
return existing.Equal(t)
|
||||
case nil:
|
||||
return existing == nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// checkFieldChanged checks if a specific field has changed
|
||||
func (fc *fieldComparator) checkFieldChanged(key string, existing *types.Issue, newVal interface{}) bool {
|
||||
switch key {
|
||||
@@ -161,8 +138,6 @@ func (fc *fieldComparator) checkFieldChanged(key string, existing *types.Issue,
|
||||
return !fc.equalStr(existing.Assignee, newVal)
|
||||
case "external_ref":
|
||||
return !fc.equalPtrStr(existing.ExternalRef, newVal)
|
||||
case "closed_at":
|
||||
return !fc.equalTime(existing.ClosedAt, newVal)
|
||||
default:
|
||||
// Unknown field - treat as changed to be conservative
|
||||
// This prevents skipping updates when new fields are added
|
||||
@@ -220,10 +195,8 @@ type ImportResult struct {
|
||||
// - Reading and parsing JSONL into issues slice
|
||||
// - Displaying results to the user
|
||||
// - Setting metadata (e.g., last_import_hash)
|
||||
// importIssuesCore is a thin wrapper around the internal/importer package
|
||||
// It converts between cmd/bd types and internal/importer types
|
||||
func importIssuesCore(ctx context.Context, dbPath string, store storage.Storage, issues []*types.Issue, opts ImportOptions) (*ImportResult, error) {
|
||||
// Convert options to importer.Options
|
||||
// Convert ImportOptions to importer.Options
|
||||
importerOpts := importer.Options{
|
||||
ResolveCollisions: opts.ResolveCollisions,
|
||||
DryRun: opts.DryRun,
|
||||
@@ -233,27 +206,156 @@ func importIssuesCore(ctx context.Context, dbPath string, store storage.Storage,
|
||||
SkipPrefixValidation: opts.SkipPrefixValidation,
|
||||
}
|
||||
|
||||
// Call the importer package
|
||||
importerResult, err := importer.ImportIssues(ctx, dbPath, store, issues, importerOpts)
|
||||
// Delegate to the importer package
|
||||
result, err := importer.ImportIssues(ctx, dbPath, store, issues, importerOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert result back to ImportResult
|
||||
result := &ImportResult{
|
||||
Created: importerResult.Created,
|
||||
Updated: importerResult.Updated,
|
||||
Unchanged: importerResult.Unchanged,
|
||||
Skipped: importerResult.Skipped,
|
||||
Collisions: importerResult.Collisions,
|
||||
IDMapping: importerResult.IDMapping,
|
||||
CollisionIDs: importerResult.CollisionIDs,
|
||||
PrefixMismatch: importerResult.PrefixMismatch,
|
||||
ExpectedPrefix: importerResult.ExpectedPrefix,
|
||||
MismatchPrefixes: importerResult.MismatchPrefixes,
|
||||
}
|
||||
// Convert importer.Result to ImportResult
|
||||
return &ImportResult{
|
||||
Created: result.Created,
|
||||
Updated: result.Updated,
|
||||
Unchanged: result.Unchanged,
|
||||
Skipped: result.Skipped,
|
||||
Collisions: result.Collisions,
|
||||
IDMapping: result.IDMapping,
|
||||
CollisionIDs: result.CollisionIDs,
|
||||
PrefixMismatch: result.PrefixMismatch,
|
||||
ExpectedPrefix: result.ExpectedPrefix,
|
||||
MismatchPrefixes: result.MismatchPrefixes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
|
||||
// renameImportedIssuePrefixes renames all issues and their references to match the target prefix
|
||||
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 := 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+"-")
|
||||
|
||||
// Validate that the numeric part is actually numeric
|
||||
if numPart == "" || !isNumeric(numPart) {
|
||||
return fmt.Errorf("cannot rename issue %s: non-numeric suffix '%s'", issue.ID, numPart)
|
||||
}
|
||||
|
||||
newID := fmt.Sprintf("%s-%s", targetPrefix, numPart)
|
||||
idMapping[issue.ID] = newID
|
||||
}
|
||||
}
|
||||
|
||||
// Now update all issues and their references
|
||||
for _, issue := range issues {
|
||||
// Update the issue ID itself if it needs renaming
|
||||
if newID, ok := idMapping[issue.ID]; ok {
|
||||
issue.ID = newID
|
||||
}
|
||||
|
||||
// Update all text references in issue fields
|
||||
issue.Title = replaceIDReferences(issue.Title, idMapping)
|
||||
issue.Description = replaceIDReferences(issue.Description, idMapping)
|
||||
if issue.Design != "" {
|
||||
issue.Design = replaceIDReferences(issue.Design, idMapping)
|
||||
}
|
||||
if issue.AcceptanceCriteria != "" {
|
||||
issue.AcceptanceCriteria = replaceIDReferences(issue.AcceptanceCriteria, idMapping)
|
||||
}
|
||||
if issue.Notes != "" {
|
||||
issue.Notes = replaceIDReferences(issue.Notes, idMapping)
|
||||
}
|
||||
|
||||
// Update dependency references
|
||||
for i := range issue.Dependencies {
|
||||
if newID, ok := idMapping[issue.Dependencies[i].IssueID]; ok {
|
||||
issue.Dependencies[i].IssueID = newID
|
||||
}
|
||||
if newID, ok := idMapping[issue.Dependencies[i].DependsOnID]; ok {
|
||||
issue.Dependencies[i].DependsOnID = newID
|
||||
}
|
||||
}
|
||||
|
||||
// Update comment references
|
||||
for i := range issue.Comments {
|
||||
issue.Comments[i].Text = replaceIDReferences(issue.Comments[i].Text, idMapping)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceIDReferences replaces all old issue ID references with new ones in text
|
||||
// Uses boundary-aware matching to avoid partial replacements (e.g., wy-1 inside wy-10)
|
||||
func replaceIDReferences(text string, idMapping map[string]string) string {
|
||||
if len(idMapping) == 0 {
|
||||
return text
|
||||
}
|
||||
|
||||
// Sort old IDs by length descending to handle longer IDs first
|
||||
// This prevents "wy-1" from being replaced inside "wy-10"
|
||||
oldIDs := make([]string, 0, len(idMapping))
|
||||
for oldID := range idMapping {
|
||||
oldIDs = append(oldIDs, oldID)
|
||||
}
|
||||
sort.Slice(oldIDs, func(i, j int) bool {
|
||||
return len(oldIDs[i]) > len(oldIDs[j])
|
||||
})
|
||||
|
||||
result := text
|
||||
for _, oldID := range oldIDs {
|
||||
newID := idMapping[oldID]
|
||||
// Replace with boundary checking
|
||||
result = replaceBoundaryAware(result, oldID, newID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// replaceBoundaryAware replaces oldID with newID only when surrounded by boundaries
|
||||
func replaceBoundaryAware(text, oldID, newID string) string {
|
||||
if !strings.Contains(text, oldID) {
|
||||
return text
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
result.Grow(len(text))
|
||||
|
||||
for i := 0; i < len(text); {
|
||||
// Check if we match oldID at this position
|
||||
if strings.HasPrefix(text[i:], oldID) {
|
||||
// Check boundaries before and after
|
||||
beforeOK := i == 0 || isBoundary(text[i-1])
|
||||
afterOK := (i+len(oldID) >= len(text)) || isBoundary(text[i+len(oldID)])
|
||||
|
||||
if beforeOK && afterOK {
|
||||
// Valid match - replace
|
||||
result.WriteString(newID)
|
||||
i += len(oldID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Not a match or invalid boundaries - keep original character
|
||||
result.WriteByte(text[i])
|
||||
i++
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// isBoundary returns true if the character is not part of an issue ID
|
||||
func isBoundary(c byte) bool {
|
||||
// Issue IDs contain: lowercase letters, digits, and hyphens
|
||||
// Boundaries are anything else (space, punctuation, etc.)
|
||||
return (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-'
|
||||
}
|
||||
|
||||
// isNumeric returns true if the string contains only digits
|
||||
@@ -265,28 +367,3 @@ func isNumeric(s string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// extractPrefix extracts the prefix from an issue ID (e.g., "bd-123" -> "bd")
|
||||
func extractPrefix(issueID string) string {
|
||||
parts := strings.SplitN(issueID, "-", 2)
|
||||
if len(parts) < 2 {
|
||||
return "" // No prefix found
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
// getPrefixList formats a map of prefix counts into a sorted list of strings
|
||||
func getPrefixList(prefixes map[string]int) []string {
|
||||
var result []string
|
||||
keys := make([]string, 0, len(prefixes))
|
||||
for k := range prefixes {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, prefix := range keys {
|
||||
count := prefixes[prefix]
|
||||
result = append(result, fmt.Sprintf("%s- (%d issues)", prefix, count))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
100
cmd/bd/rename_prefix_repair_test.go
Normal file
100
cmd/bd/rename_prefix_repair_test.go
Normal 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)")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,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"
|
||||
)
|
||||
|
||||
// Options contains import configuration
|
||||
@@ -154,7 +155,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]++
|
||||
@@ -163,7 +164,7 @@ func handlePrefixMismatch(ctx context.Context, sqliteStore *sqlite.SQLiteStorage
|
||||
|
||||
// If prefix mismatch detected and not handling it, return error or warning
|
||||
if result.PrefixMismatch && !opts.RenameOnImport && !opts.DryRun && !opts.SkipPrefixValidation {
|
||||
return fmt.Errorf("prefix mismatch detected: database uses '%s-' but found issues with prefixes: %v (use --rename-on-import to automatically fix)", configuredPrefix, getPrefixList(result.MismatchPrefixes))
|
||||
return fmt.Errorf("prefix mismatch detected: database uses '%s-' but found issues with prefixes: %v (use --rename-on-import to automatically fix)", configuredPrefix, GetPrefixList(result.MismatchPrefixes))
|
||||
}
|
||||
|
||||
// Handle rename-on-import if requested
|
||||
@@ -437,15 +438,7 @@ 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 ""
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
func getPrefixList(prefixes map[string]int) []string {
|
||||
func GetPrefixList(prefixes map[string]int) []string {
|
||||
var result []string
|
||||
keys := make([]string, 0, len(prefixes))
|
||||
for k := range prefixes {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/utils"
|
||||
)
|
||||
|
||||
// IssueDataChanged checks if an issue's data has changed from the database version
|
||||
@@ -144,7 +145,7 @@ func RenameImportedIssuePrefixes(issues []*types.Issue, targetPrefix string) err
|
||||
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)
|
||||
}
|
||||
|
||||
26
internal/utils/issue_id.go
Normal file
26
internal/utils/issue_id.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user