Resolve merge conflicts: use importer package

This commit is contained in:
Steve Yegge
2025-10-27 22:44:40 -07:00
8 changed files with 513 additions and 92 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,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 {

View File

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

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

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

View File

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

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
}