Merge pull request #584 from rsnodgrass/repair-hashes
fix(rename-prefix): use hash IDs instead of sequential in --repair mode
This commit is contained in:
@@ -593,8 +593,18 @@ func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues
|
||||
// Exact match (same content, same ID) - idempotent case
|
||||
result.Unchanged++
|
||||
} else {
|
||||
// Same content, different ID - rename detected
|
||||
if !opts.SkipUpdate {
|
||||
// Same content, different ID - check if this is a rename or cross-prefix duplicate
|
||||
existingPrefix := utils.ExtractIssuePrefix(existing.ID)
|
||||
incomingPrefix := utils.ExtractIssuePrefix(incoming.ID)
|
||||
|
||||
if existingPrefix != incomingPrefix {
|
||||
// Cross-prefix content match: same content but different projects/prefixes.
|
||||
// This is NOT a rename - it's a duplicate from another project.
|
||||
// Skip the incoming issue and keep the existing one unchanged.
|
||||
// Calling handleRename would fail because CreateIssue validates prefix.
|
||||
result.Skipped++
|
||||
} else if !opts.SkipUpdate {
|
||||
// Same prefix, different ID suffix - this is a true rename
|
||||
deletedID, err := handleRename(ctx, sqliteStore, existing, incoming)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to handle rename %s -> %s: %w", existing.ID, incoming.ID, err)
|
||||
|
||||
@@ -524,7 +524,7 @@ func TestIsBoundary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNumeric(t *testing.T) {
|
||||
func TestIsValidIDSuffix(t *testing.T) {
|
||||
tests := []struct {
|
||||
s string
|
||||
want bool
|
||||
@@ -538,18 +538,24 @@ func TestIsNumeric(t *testing.T) {
|
||||
{"09ea", true},
|
||||
{"abc123", true},
|
||||
{"zzz", true},
|
||||
// Hierarchical suffixes (hash.number format)
|
||||
{"6we.2", true},
|
||||
{"abc.1", true},
|
||||
{"abc.1.2", true},
|
||||
{"abc.1.2.3", true},
|
||||
{"1.5", true},
|
||||
// Invalid suffixes
|
||||
{"", false}, // Empty string now returns false
|
||||
{"1.5", false}, // Non-base36 characters
|
||||
{"A3F8", false}, // Uppercase not allowed
|
||||
{"@#$!", false}, // Special characters not allowed
|
||||
{"", false}, // Empty string
|
||||
{"A3F8", false}, // Uppercase not allowed
|
||||
{"@#$!", false}, // Special characters not allowed
|
||||
{"abc-def", false}, // Hyphens not allowed in suffix
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.s, func(t *testing.T) {
|
||||
got := isNumeric(tt.s)
|
||||
got := isValidIDSuffix(tt.s)
|
||||
if got != tt.want {
|
||||
t.Errorf("isNumeric(%q) = %v, want %v", tt.s, got, tt.want)
|
||||
t.Errorf("isValidIDSuffix(%q) = %v, want %v", tt.s, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1498,3 +1504,95 @@ func TestImportOrphanSkip_CountMismatch(t *testing.T) {
|
||||
t.Errorf("Expected 2 normal issues in database, found %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportCrossPrefixContentMatch tests that importing an issue with a different prefix
|
||||
// but same content hash does NOT trigger a rename operation.
|
||||
//
|
||||
// Bug scenario:
|
||||
// 1. DB has issue "alpha-abc123" with prefix "alpha" configured
|
||||
// 2. Incoming JSONL has "beta-xyz789" with same content (same hash)
|
||||
// 3. Content hash match triggers rename detection (same content, different ID)
|
||||
// 4. handleRename tries to create "beta-xyz789" which fails prefix validation
|
||||
//
|
||||
// Expected behavior: Skip the cross-prefix "rename" and keep the existing issue unchanged.
|
||||
func TestImportCrossPrefixContentMatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDB := t.TempDir() + "/test.db"
|
||||
store, err := sqlite.New(context.Background(), tmpDB)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Configure database with "alpha" prefix
|
||||
if err := store.SetConfig(ctx, "issue_prefix", "alpha"); err != nil {
|
||||
t.Fatalf("Failed to set prefix: %v", err)
|
||||
}
|
||||
|
||||
// Create an issue with the configured prefix
|
||||
existingIssue := &types.Issue{
|
||||
ID: "alpha-abc123",
|
||||
Title: "Shared Content Issue",
|
||||
Description: "This issue has content that will match a cross-prefix import",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, existingIssue, "test-setup"); err != nil {
|
||||
t.Fatalf("Failed to create existing issue: %v", err)
|
||||
}
|
||||
|
||||
// Compute the content hash of the existing issue
|
||||
existingHash := existingIssue.ComputeContentHash()
|
||||
|
||||
// Create an incoming issue with DIFFERENT prefix but SAME content
|
||||
// This simulates importing from another project with same issue content
|
||||
incomingIssue := &types.Issue{
|
||||
ID: "beta-xyz789", // Different prefix!
|
||||
Title: "Shared Content Issue",
|
||||
Description: "This issue has content that will match a cross-prefix import",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
|
||||
// Verify they have the same content hash (this is what triggers the bug)
|
||||
incomingHash := incomingIssue.ComputeContentHash()
|
||||
if existingHash != incomingHash {
|
||||
t.Fatalf("Test setup error: content hashes should match. existing=%s incoming=%s", existingHash, incomingHash)
|
||||
}
|
||||
|
||||
// Import the cross-prefix issue with SkipPrefixValidation (simulates auto-import behavior)
|
||||
// This should NOT fail - cross-prefix content matches should be skipped, not renamed
|
||||
result, err := ImportIssues(ctx, tmpDB, store, []*types.Issue{incomingIssue}, Options{
|
||||
SkipPrefixValidation: true, // Auto-import typically sets this
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Import should not fail for cross-prefix content match: %v", err)
|
||||
}
|
||||
|
||||
// The incoming issue should be skipped (not created, not updated)
|
||||
// because it has a different prefix than configured
|
||||
if result.Created != 0 {
|
||||
t.Errorf("Expected 0 created (cross-prefix should be skipped), got %d", result.Created)
|
||||
}
|
||||
|
||||
// The existing issue should remain unchanged
|
||||
retrieved, err := store.GetIssue(ctx, "alpha-abc123")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve existing issue: %v", err)
|
||||
}
|
||||
if retrieved == nil {
|
||||
t.Fatal("Existing issue alpha-abc123 should still exist after import")
|
||||
}
|
||||
if retrieved.Title != "Shared Content Issue" {
|
||||
t.Errorf("Existing issue should be unchanged, got title: %s", retrieved.Title)
|
||||
}
|
||||
|
||||
// The cross-prefix issue should NOT exist in the database
|
||||
crossPrefix, err := store.GetIssue(ctx, "beta-xyz789")
|
||||
if err == nil && crossPrefix != nil {
|
||||
t.Error("Cross-prefix issue beta-xyz789 should NOT be created in the database")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,19 @@ func (fc *fieldComparator) checkFieldChanged(key string, existing *types.Issue,
|
||||
}
|
||||
}
|
||||
|
||||
// RenameImportedIssuePrefixes renames all issues and their references to match the target prefix
|
||||
// RenameImportedIssuePrefixes renames all issues and their references to match the target prefix.
|
||||
//
|
||||
// This function handles three ID formats:
|
||||
// - Sequential numeric IDs: "old-123" → "new-123"
|
||||
// - Hash-based IDs: "old-abc1" → "new-abc1"
|
||||
// - Hierarchical IDs: "old-abc1.2.3" → "new-abc1.2.3"
|
||||
//
|
||||
// The suffix (everything after "prefix-") is preserved during rename, only the prefix changes.
|
||||
// This preserves issue identity across prefix renames while maintaining parent-child relationships
|
||||
// in hierarchical IDs (dots denote subtask nesting, e.g., bd-abc1.2 is child 2 of bd-abc1).
|
||||
//
|
||||
// All text references to old IDs in issue fields (title, description, notes, etc.) and
|
||||
// dependency relationships are updated to use the new IDs.
|
||||
func RenameImportedIssuePrefixes(issues []*types.Issue, targetPrefix string) error {
|
||||
// Build a mapping of old IDs to new IDs
|
||||
idMapping := make(map[string]string)
|
||||
@@ -151,15 +163,15 @@ func RenameImportedIssuePrefixes(issues []*types.Issue, targetPrefix string) err
|
||||
}
|
||||
|
||||
if oldPrefix != targetPrefix {
|
||||
// Extract the numeric part
|
||||
numPart := strings.TrimPrefix(issue.ID, oldPrefix+"-")
|
||||
// Extract the suffix part (supports both numeric "123" and hash "abc1" and hierarchical "abc.1.2")
|
||||
suffix := 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)
|
||||
// Validate that the suffix is valid (alphanumeric + dots for hierarchy)
|
||||
if suffix == "" || !isValidIDSuffix(suffix) {
|
||||
return fmt.Errorf("cannot rename issue %s: invalid suffix '%s'", issue.ID, suffix)
|
||||
}
|
||||
|
||||
newID := fmt.Sprintf("%s-%s", targetPrefix, numPart)
|
||||
newID := fmt.Sprintf("%s-%s", targetPrefix, suffix)
|
||||
idMapping[issue.ID] = newID
|
||||
}
|
||||
}
|
||||
@@ -270,13 +282,24 @@ func isBoundary(c byte) bool {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',' || c == '.' || c == '!' || c == '?' || c == ':' || c == ';' || c == '(' || c == ')' || c == '[' || c == ']' || c == '{' || c == '}'
|
||||
}
|
||||
|
||||
func isNumeric(s string) bool {
|
||||
// isValidIDSuffix validates the suffix portion of an issue ID (everything after "prefix-").
|
||||
//
|
||||
// Beads supports three ID formats, all of which this function must accept:
|
||||
// - Sequential numeric: "123", "999" (legacy format)
|
||||
// - Hash-based (base36): "abc1", "6we", "zzz" (current format, content-addressed)
|
||||
// - Hierarchical: "abc1.2", "6we.2.3" (subtasks, dot-separated child counters)
|
||||
//
|
||||
// The dot separator in hierarchical IDs represents parent-child relationships:
|
||||
// "bd-abc1.2" means child #2 of parent "bd-abc1". Maximum depth is 3 levels.
|
||||
//
|
||||
// Rejected: uppercase letters, hyphens (would be confused with prefix separator),
|
||||
// and special characters.
|
||||
func isValidIDSuffix(s string) bool {
|
||||
if len(s) == 0 {
|
||||
return false
|
||||
}
|
||||
// Accept base36 characters (0-9, a-z) for hash-based suffixes
|
||||
for _, c := range s {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z')) {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || c == '.') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user