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:
Steve Yegge
2025-12-16 00:42:32 -08:00
committed by GitHub
7 changed files with 1155 additions and 291 deletions

View File

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

View File

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

View File

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