diff --git a/internal/importer/importer.go b/internal/importer/importer.go index 4bcaeb35..2b05d87f 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -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) diff --git a/internal/importer/importer_test.go b/internal/importer/importer_test.go index d09d0c55..ac6f6163 100644 --- a/internal/importer/importer_test.go +++ b/internal/importer/importer_test.go @@ -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") + } +}