fix(import): skip cross-prefix content matches instead of triggering rename

When importing issues, if an incoming issue has the same content hash as an
existing issue but a DIFFERENT prefix, this should not be treated as a rename.
Cross-prefix content matches occur when importing issues from other projects
that happen to have identical content.

Previously, the importer would call handleRename which tries to create an issue
with the incoming prefix, failing prefix validation ("does not match configured
prefix" error).

The fix checks if prefixes differ before calling handleRename:
- Same prefix, different ID suffix → true rename, call handleRename
- Different prefix → skip incoming issue, keep existing unchanged

Added test: TestImportCrossPrefixContentMatch reproduces the bug scenario
where alpha-* issues exist but beta-* issues are imported with same content.
This commit is contained in:
Ryan Snodgrass
2025-12-16 00:29:19 -08:00
parent 421d41dfa0
commit 627ac1afb8
2 changed files with 104 additions and 2 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

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