Make DetectCollisions read-only (bd-96)
- Add RenameDetail type to track content matches with different IDs - Remove deletion logic from DetectCollisions (now read-only) - Create ApplyCollisionResolution to handle all modifications - Update importer.go to use two-phase approach (detect then apply) - Fix dependency preservation in RemapCollisions - Collect all dependencies before CASCADE DELETE - Recreate with updated IDs after remapping - Add tests: TestDetectCollisionsReadOnly, TestApplyCollisionResolution - Update collision tests for content-hash scoring behavior - Create bd-100 to track fixing autoimport tests
This commit is contained in:
@@ -141,45 +141,52 @@ func TestRemapCollisionsRemapsImportedNotExisting(t *testing.T) {
|
|||||||
t.Fatalf("RemapCollisions failed: %v", err)
|
t.Fatalf("RemapCollisions failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Verify existing issue dependencies are preserved
|
// Step 5: Verify dependencies are preserved on remapped issues
|
||||||
t.Logf("\n=== Verifying Existing Issue Dependencies ===")
|
// With content-hash scoring, all existing issues get remapped to new IDs
|
||||||
|
t.Logf("\n=== Verifying Dependencies Preserved on Remapped Issues ===")
|
||||||
|
t.Logf("ID Mappings: %v", idMapping)
|
||||||
|
|
||||||
// Check bd-1 → bd-2 dependency (created before import)
|
// The new bd-1, bd-2, bd-3 (incoming issues) should have NO dependencies
|
||||||
existingDeps1, _ := store.GetDependencyRecords(ctx, "bd-1")
|
newBD1Deps, _ := store.GetDependencyRecords(ctx, "bd-1")
|
||||||
t.Logf("bd-1 dependencies: %d (expected: 1)", len(existingDeps1))
|
if len(newBD1Deps) != 0 {
|
||||||
|
t.Errorf("Expected 0 dependencies for new bd-1 (incoming), got %d", len(newBD1Deps))
|
||||||
if len(existingDeps1) == 0 {
|
|
||||||
t.Errorf("BUG CONFIRMED: Existing bd-1 has ZERO dependencies after import!")
|
|
||||||
t.Errorf(" Expected: bd-1 → bd-2 (created by test before import)")
|
|
||||||
t.Errorf(" Actual: All dependencies deleted by updateDependencyReferences()")
|
|
||||||
} else if len(existingDeps1) != 1 {
|
|
||||||
t.Errorf("Expected 1 dependency for bd-1, got %d", len(existingDeps1))
|
|
||||||
} else {
|
|
||||||
// Verify the dependency is correct
|
|
||||||
if existingDeps1[0].DependsOnID != "bd-2" {
|
|
||||||
t.Errorf("Expected bd-1 → bd-2, got bd-1 → %s", existingDeps1[0].DependsOnID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check bd-3 → bd-1 dependency (created before import)
|
newBD3Deps, _ := store.GetDependencyRecords(ctx, "bd-3")
|
||||||
existingDeps3, _ := store.GetDependencyRecords(ctx, "bd-3")
|
if len(newBD3Deps) != 0 {
|
||||||
t.Logf("bd-3 dependencies: %d (expected: 1)", len(existingDeps3))
|
t.Errorf("Expected 0 dependencies for new bd-3 (incoming), got %d", len(newBD3Deps))
|
||||||
|
|
||||||
if len(existingDeps3) == 0 {
|
|
||||||
t.Errorf("BUG CONFIRMED: Existing bd-3 has ZERO dependencies after import!")
|
|
||||||
t.Errorf(" Expected: bd-3 → bd-1 (created by test before import)")
|
|
||||||
t.Errorf(" Actual: All dependencies deleted by updateDependencyReferences()")
|
|
||||||
} else if len(existingDeps3) != 1 {
|
|
||||||
t.Errorf("Expected 1 dependency for bd-3, got %d", len(existingDeps3))
|
|
||||||
} else {
|
|
||||||
// Verify the dependency is correct
|
|
||||||
if existingDeps3[0].DependsOnID != testIssueBD1 {
|
|
||||||
t.Errorf("Expected bd-3 → bd-1, got bd-3 → %s", existingDeps3[0].DependsOnID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("\nID Mappings: %v", idMapping)
|
// The remapped issues should have their dependencies preserved
|
||||||
t.Logf("Fix verified: Existing issue dependencies preserved during collision resolution")
|
remappedBD1 := idMapping["bd-1"] // Old bd-1 → new ID
|
||||||
|
remappedBD2 := idMapping["bd-2"] // Old bd-2 → new ID
|
||||||
|
remappedBD3 := idMapping["bd-3"] // Old bd-3 → new ID
|
||||||
|
|
||||||
|
// Check remapped bd-1's dependency (was bd-1 → bd-2, now should be remappedBD1 → remappedBD2)
|
||||||
|
remappedBD1Deps, _ := store.GetDependencyRecords(ctx, remappedBD1)
|
||||||
|
t.Logf("%s dependencies: %d (expected: 1)", remappedBD1, len(remappedBD1Deps))
|
||||||
|
|
||||||
|
if len(remappedBD1Deps) != 1 {
|
||||||
|
t.Errorf("Expected 1 dependency for remapped %s (preserved from old bd-1), got %d",
|
||||||
|
remappedBD1, len(remappedBD1Deps))
|
||||||
|
} else if remappedBD1Deps[0].DependsOnID != remappedBD2 {
|
||||||
|
t.Errorf("Expected %s → %s, got %s → %s",
|
||||||
|
remappedBD1, remappedBD2, remappedBD1, remappedBD1Deps[0].DependsOnID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check remapped bd-3's dependency (was bd-3 → bd-1, now should be remappedBD3 → remappedBD1)
|
||||||
|
remappedBD3Deps, _ := store.GetDependencyRecords(ctx, remappedBD3)
|
||||||
|
t.Logf("%s dependencies: %d (expected: 1)", remappedBD3, len(remappedBD3Deps))
|
||||||
|
|
||||||
|
if len(remappedBD3Deps) != 1 {
|
||||||
|
t.Errorf("Expected 1 dependency for remapped %s (preserved from old bd-3), got %d",
|
||||||
|
remappedBD3, len(remappedBD3Deps))
|
||||||
|
} else if remappedBD3Deps[0].DependsOnID != remappedBD1 {
|
||||||
|
t.Errorf("Expected %s → %s, got %s → %s",
|
||||||
|
remappedBD3, remappedBD1, remappedBD3, remappedBD3Deps[0].DependsOnID)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Fix verified: Dependencies preserved correctly on remapped issues with content-hash scoring")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRemapCollisionsDoesNotUpdateNonexistentDependencies verifies that
|
// TestRemapCollisionsDoesNotUpdateNonexistentDependencies verifies that
|
||||||
@@ -264,32 +271,37 @@ func TestRemapCollisionsDoesNotUpdateNonexistentDependencies(t *testing.T) {
|
|||||||
t.Fatalf("RemapCollisions failed: %v", err)
|
t.Fatalf("RemapCollisions failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Verify existing dependency is untouched
|
// Step 3: Verify dependencies are preserved correctly
|
||||||
existingDeps, err := store.GetDependencyRecords(ctx, "bd-1")
|
// With content-hash scoring: existing hash > incoming hash, so RemapIncoming=false
|
||||||
if err != nil {
|
// This means: existing bd-1 → remapped to new ID, incoming bd-1 takes over bd-1
|
||||||
t.Fatalf("failed to get dependencies for bd-1: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(existingDeps) != 1 {
|
// The remapped issue (old bd-1) should have its dependency preserved
|
||||||
t.Errorf("Expected 1 dependency for existing bd-1, got %d (dependency should not be touched)", len(existingDeps))
|
|
||||||
} else {
|
|
||||||
if existingDeps[0].DependsOnID != testIssueBD2 {
|
|
||||||
t.Errorf("Expected bd-1 → bd-2, got bd-1 → %s", existingDeps[0].DependsOnID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the remapped issue exists but has no dependencies
|
|
||||||
// (because dependencies are imported later in Phase 5)
|
|
||||||
remappedID := idMapping["bd-1"]
|
remappedID := idMapping["bd-1"]
|
||||||
remappedDeps, err := store.GetDependencyRecords(ctx, remappedID)
|
remappedDeps, err := store.GetDependencyRecords(ctx, remappedID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to get dependencies for %s: %v", remappedID, err)
|
t.Fatalf("failed to get dependencies for %s: %v", remappedID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(remappedDeps) != 0 {
|
if len(remappedDeps) != 1 {
|
||||||
t.Errorf("Expected 0 dependencies for remapped %s (dependencies added later), got %d",
|
t.Errorf("Expected 1 dependency for remapped %s (preserved from old bd-1), got %d",
|
||||||
remappedID, len(remappedDeps))
|
remappedID, len(remappedDeps))
|
||||||
|
} else {
|
||||||
|
// The dependency should now be remappedID → bd-2 (updated from bd-1 → bd-2)
|
||||||
|
if remappedDeps[0].DependsOnID != testIssueBD2 {
|
||||||
|
t.Errorf("Expected %s → bd-2, got %s → %s", remappedID, remappedID, remappedDeps[0].DependsOnID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("Verified: updateDependencyReferences is effectively a no-op when no remapped dependencies exist")
|
// The new bd-1 (incoming issue) should have no dependencies
|
||||||
|
// (because dependencies are imported later in Phase 5)
|
||||||
|
newBD1Deps, err := store.GetDependencyRecords(ctx, "bd-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get dependencies for bd-1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(newBD1Deps) != 0 {
|
||||||
|
t.Errorf("Expected 0 dependencies for new bd-1 (dependencies added later), got %d", len(newBD1Deps))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Verified: Dependencies preserved correctly during collision resolution with content-hash scoring")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ func handlePrefixMismatch(ctx context.Context, sqliteStore *sqlite.SQLiteStorage
|
|||||||
|
|
||||||
// handleCollisions detects and resolves ID collisions
|
// handleCollisions detects and resolves ID collisions
|
||||||
func handleCollisions(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues []*types.Issue, opts Options, result *Result) ([]*types.Issue, error) {
|
func handleCollisions(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues []*types.Issue, opts Options, result *Result) ([]*types.Issue, error) {
|
||||||
|
// Phase 1: Detect (read-only)
|
||||||
collisionResult, err := sqlite.DetectCollisions(ctx, sqliteStore, issues)
|
collisionResult, err := sqlite.DetectCollisions(ctx, sqliteStore, issues)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("collision detection failed: %w", err)
|
return nil, fmt.Errorf("collision detection failed: %w", err)
|
||||||
@@ -215,12 +216,12 @@ func handleCollisions(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, is
|
|||||||
return nil, fmt.Errorf("failed to get existing issues for collision resolution: %w", err)
|
return nil, fmt.Errorf("failed to get existing issues for collision resolution: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score collisions
|
// Phase 2: Score collisions
|
||||||
if err := sqlite.ScoreCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues); err != nil {
|
if err := sqlite.ScoreCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues); err != nil {
|
||||||
return nil, fmt.Errorf("failed to score collisions: %w", err)
|
return nil, fmt.Errorf("failed to score collisions: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remap collisions
|
// Phase 3: Remap collisions
|
||||||
idMapping, err := sqlite.RemapCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues)
|
idMapping, err := sqlite.RemapCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to remap collisions: %w", err)
|
return nil, fmt.Errorf("failed to remap collisions: %w", err)
|
||||||
@@ -243,8 +244,20 @@ func handleCollisions(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, is
|
|||||||
return filteredIssues, nil
|
return filteredIssues, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 4: Apply renames (deletions of old IDs) if any were detected
|
||||||
|
if len(collisionResult.Renames) > 0 && !opts.DryRun {
|
||||||
|
// Build mapping for renames: oldID -> newID
|
||||||
|
renameMapping := make(map[string]string)
|
||||||
|
for _, rename := range collisionResult.Renames {
|
||||||
|
renameMapping[rename.OldID] = rename.NewID
|
||||||
|
}
|
||||||
|
if err := sqlite.ApplyCollisionResolution(ctx, sqliteStore, collisionResult, renameMapping); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to apply rename resolutions: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if opts.DryRun {
|
if opts.DryRun {
|
||||||
result.Created = len(collisionResult.NewIssues)
|
result.Created = len(collisionResult.NewIssues) + len(collisionResult.Renames)
|
||||||
result.Unchanged = len(collisionResult.ExactMatches)
|
result.Unchanged = len(collisionResult.ExactMatches)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ type CollisionResult struct {
|
|||||||
ExactMatches []string // IDs that match exactly (idempotent import)
|
ExactMatches []string // IDs that match exactly (idempotent import)
|
||||||
Collisions []*CollisionDetail // Issues with same ID but different content
|
Collisions []*CollisionDetail // Issues with same ID but different content
|
||||||
NewIssues []string // IDs that don't exist in DB yet
|
NewIssues []string // IDs that don't exist in DB yet
|
||||||
|
Renames []*RenameDetail // Issues with same content but different ID (renames)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameDetail captures a rename/remap detected during collision detection
|
||||||
|
type RenameDetail struct {
|
||||||
|
OldID string // ID in database (to be deleted)
|
||||||
|
NewID string // ID in incoming (to be created)
|
||||||
|
Issue *types.Issue // The issue with new ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// CollisionDetail provides detailed information about a collision
|
// CollisionDetail provides detailed information about a collision
|
||||||
@@ -74,16 +82,13 @@ func DetectCollisions(ctx context.Context, s *SQLiteStorage, incomingIssues []*t
|
|||||||
if dbMatch, found := contentToDBIssue[incomingHash]; found {
|
if dbMatch, found := contentToDBIssue[incomingHash]; found {
|
||||||
// Same content, different ID - this is a rename/remap
|
// Same content, different ID - this is a rename/remap
|
||||||
// The incoming ID is the NEW canonical ID, existing DB ID is OLD
|
// The incoming ID is the NEW canonical ID, existing DB ID is OLD
|
||||||
// We should DELETE the old ID and ACCEPT the new one
|
// Record this as a rename to be handled later (read-only detection)
|
||||||
// Mark this as new issue (it will be created later)
|
result.Renames = append(result.Renames, &RenameDetail{
|
||||||
// and we'll handle deletion of old ID separately
|
OldID: dbMatch.ID,
|
||||||
result.NewIssues = append(result.NewIssues, incoming.ID)
|
NewID: incoming.ID,
|
||||||
|
Issue: incoming,
|
||||||
// Delete the old DB issue (content match with different ID)
|
})
|
||||||
if err := s.DeleteIssue(ctx, dbMatch.ID); err != nil {
|
// Don't add to NewIssues - will be handled by ApplyCollisionResolution
|
||||||
return nil, fmt.Errorf("failed to delete renamed issue %s (renamed to %s): %w",
|
|
||||||
dbMatch.ID, incoming.ID, err)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Truly new issue
|
// Truly new issue
|
||||||
result.NewIssues = append(result.NewIssues, incoming.ID)
|
result.NewIssues = append(result.NewIssues, incoming.ID)
|
||||||
@@ -213,6 +218,32 @@ func hashIssueContent(issue *types.Issue) string {
|
|||||||
return fmt.Sprintf("%x", h.Sum(nil))
|
return fmt.Sprintf("%x", h.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApplyCollisionResolution applies the modifications detected during collision detection.
|
||||||
|
// This function handles:
|
||||||
|
// 1. Rename deletions (delete old IDs for renamed issues)
|
||||||
|
// 2. Creating remapped issues (based on mapping)
|
||||||
|
// 3. Updating all references to use new IDs
|
||||||
|
//
|
||||||
|
// This is the write-phase counterpart to the read-only DetectCollisions.
|
||||||
|
func ApplyCollisionResolution(ctx context.Context, s *SQLiteStorage, result *CollisionResult, mapping map[string]string) error {
|
||||||
|
// Phase 1: Handle renames (delete old IDs)
|
||||||
|
for _, rename := range result.Renames {
|
||||||
|
if err := s.DeleteIssue(ctx, rename.OldID); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete renamed issue %s (renamed to %s): %w",
|
||||||
|
rename.OldID, rename.NewID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Update references using the mapping
|
||||||
|
if len(mapping) > 0 {
|
||||||
|
if err := updateReferences(ctx, s, mapping); err != nil {
|
||||||
|
return fmt.Errorf("failed to update references: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ScoreCollisions determines which version of each colliding issue to keep vs. remap.
|
// ScoreCollisions determines which version of each colliding issue to keep vs. remap.
|
||||||
// Uses deterministic content-based hashing to ensure all clones make the same decision.
|
// Uses deterministic content-based hashing to ensure all clones make the same decision.
|
||||||
//
|
//
|
||||||
@@ -373,7 +404,14 @@ func RemapCollisions(ctx context.Context, s *SQLiteStorage, collisions []*Collis
|
|||||||
return nil, fmt.Errorf("failed to sync ID counters: %w", err)
|
return nil, fmt.Errorf("failed to sync ID counters: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each collision based on which version should be remapped
|
// Step 1: Collect ALL dependencies before any modifications
|
||||||
|
// This prevents CASCADE DELETE from losing dependency information
|
||||||
|
allDepsBeforeRemap, err := s.GetAllDependencyRecords(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get all dependencies: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Process each collision based on which version should be remapped
|
||||||
for _, collision := range collisions {
|
for _, collision := range collisions {
|
||||||
// Skip collisions with nil issues (shouldn't happen but be defensive)
|
// Skip collisions with nil issues (shouldn't happen but be defensive)
|
||||||
if collision.IncomingIssue == nil {
|
if collision.IncomingIssue == nil {
|
||||||
@@ -410,7 +448,7 @@ func RemapCollisions(ctx context.Context, s *SQLiteStorage, collisions []*Collis
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Existing has higher hash -> remap existing, replace with incoming
|
// Existing has higher hash -> remap existing, replace with incoming
|
||||||
// First, remap the existing issue to new ID
|
// Record mapping FIRST before any operations
|
||||||
idMapping[oldID] = newID
|
idMapping[oldID] = newID
|
||||||
|
|
||||||
// Create a copy of existing issue with new ID
|
// Create a copy of existing issue with new ID
|
||||||
@@ -421,19 +459,70 @@ func RemapCollisions(ctx context.Context, s *SQLiteStorage, collisions []*Collis
|
|||||||
return nil, fmt.Errorf("failed to create remapped existing issue %s -> %s: %w", oldID, newID, err)
|
return nil, fmt.Errorf("failed to create remapped existing issue %s -> %s: %w", oldID, newID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the existing issue with old ID
|
// Create incoming issue with original ID (this will REPLACE when we delete old ID)
|
||||||
|
// We do this BEFORE deleting so both issues exist temporarily
|
||||||
|
// Note: This will fail if incoming ID already exists, which is expected in this flow
|
||||||
|
// So we skip this step and do it after deletion
|
||||||
|
|
||||||
|
// Note: We do NOT copy dependencies here - DeleteIssue will cascade delete them
|
||||||
|
// But we've already recorded the mapping, so updateReferences will fix everything
|
||||||
|
// after all collisions are processed
|
||||||
|
|
||||||
|
// Delete the existing issue with old ID (this will cascade delete old dependencies)
|
||||||
if err := s.DeleteIssue(ctx, oldID); err != nil {
|
if err := s.DeleteIssue(ctx, oldID); err != nil {
|
||||||
return nil, fmt.Errorf("failed to delete old existing issue %s: %w", oldID, err)
|
return nil, fmt.Errorf("failed to delete old existing issue %s: %w", oldID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create incoming issue with original ID (replaces the deleted one)
|
// NOW create incoming issue with original ID (replaces the deleted one)
|
||||||
if err := s.CreateIssue(ctx, collision.IncomingIssue, "import-replace"); err != nil {
|
if err := s.CreateIssue(ctx, collision.IncomingIssue, "import-replace"); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create incoming issue %s: %w", oldID, err)
|
return nil, fmt.Errorf("failed to create incoming issue %s: %w", oldID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now update all references in text fields and dependencies
|
// Step 3: Recreate dependencies with updated IDs
|
||||||
|
// For each dependency that involved a remapped issue, recreate it with new IDs
|
||||||
|
for issueID, deps := range allDepsBeforeRemap {
|
||||||
|
for _, dep := range deps {
|
||||||
|
// Determine new IDs (use mapping if available, otherwise keep original)
|
||||||
|
newIssueID := issueID
|
||||||
|
if mappedID, ok := idMapping[issueID]; ok {
|
||||||
|
newIssueID = mappedID
|
||||||
|
}
|
||||||
|
|
||||||
|
newDependsOnID := dep.DependsOnID
|
||||||
|
if mappedID, ok := idMapping[dep.DependsOnID]; ok {
|
||||||
|
newDependsOnID = mappedID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only recreate if at least one ID was remapped
|
||||||
|
if newIssueID != issueID || newDependsOnID != dep.DependsOnID {
|
||||||
|
// Check if both issues still exist (the source might have been replaced)
|
||||||
|
sourceExists, err := s.GetIssue(ctx, newIssueID)
|
||||||
|
if err != nil || sourceExists == nil {
|
||||||
|
continue // Skip if source was deleted/replaced
|
||||||
|
}
|
||||||
|
|
||||||
|
targetExists, err := s.GetIssue(ctx, newDependsOnID)
|
||||||
|
if err != nil || targetExists == nil {
|
||||||
|
continue // Skip if target doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the dependency with new IDs
|
||||||
|
newDep := &types.Dependency{
|
||||||
|
IssueID: newIssueID,
|
||||||
|
DependsOnID: newDependsOnID,
|
||||||
|
Type: dep.Type,
|
||||||
|
}
|
||||||
|
if err := s.addDependencyUnchecked(ctx, newDep, "import-remap"); err != nil {
|
||||||
|
// Ignore duplicate dependency errors
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Update all text field references
|
||||||
if err := updateReferences(ctx, s, idMapping); err != nil {
|
if err := updateReferences(ctx, s, idMapping); err != nil {
|
||||||
return nil, fmt.Errorf("failed to update references: %w", err)
|
return nil, fmt.Errorf("failed to update references: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -998,3 +998,146 @@ func BenchmarkReplaceIDReferencesMultipleTexts(b *testing.B) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDetectCollisionsReadOnly verifies that DetectCollisions does not modify the database
|
||||||
|
func TestDetectCollisionsReadOnly(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "collision-readonly-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
dbPath := filepath.Join(tmpDir, "test.db")
|
||||||
|
store, err := New(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create storage: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
||||||
|
t.Fatalf("failed to set issue_prefix: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an issue in the database
|
||||||
|
dbIssue := &types.Issue{
|
||||||
|
ID: "bd-1",
|
||||||
|
Title: "Original issue",
|
||||||
|
Description: "Original content",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
}
|
||||||
|
if err := store.CreateIssue(ctx, dbIssue, "test"); err != nil {
|
||||||
|
t.Fatalf("failed to create DB issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create incoming issue with SAME CONTENT but DIFFERENT ID (rename scenario)
|
||||||
|
incomingIssue := &types.Issue{
|
||||||
|
ID: "bd-100",
|
||||||
|
Title: "Original issue",
|
||||||
|
Description: "Original content",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call DetectCollisions
|
||||||
|
result, err := DetectCollisions(ctx, store, []*types.Issue{incomingIssue})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DetectCollisions failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify rename was detected
|
||||||
|
if len(result.Renames) != 1 {
|
||||||
|
t.Fatalf("expected 1 rename, got %d", len(result.Renames))
|
||||||
|
}
|
||||||
|
if result.Renames[0].OldID != "bd-1" {
|
||||||
|
t.Errorf("expected OldID bd-1, got %s", result.Renames[0].OldID)
|
||||||
|
}
|
||||||
|
if result.Renames[0].NewID != "bd-100" {
|
||||||
|
t.Errorf("expected NewID bd-100, got %s", result.Renames[0].NewID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Verify the old issue still exists in the database (not deleted)
|
||||||
|
oldIssue, err := store.GetIssue(ctx, "bd-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get old issue: %v", err)
|
||||||
|
}
|
||||||
|
if oldIssue == nil {
|
||||||
|
t.Fatal("old issue bd-1 was deleted - DetectCollisions is not read-only!")
|
||||||
|
}
|
||||||
|
if oldIssue.Title != "Original issue" {
|
||||||
|
t.Errorf("old issue was modified - expected title 'Original issue', got '%s'", oldIssue.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestApplyCollisionResolution verifies that ApplyCollisionResolution correctly applies renames
|
||||||
|
func TestApplyCollisionResolution(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "apply-resolution-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
dbPath := filepath.Join(tmpDir, "test.db")
|
||||||
|
store, err := New(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create storage: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
||||||
|
t.Fatalf("failed to set issue_prefix: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an issue to be renamed
|
||||||
|
oldIssue := &types.Issue{
|
||||||
|
ID: "bd-1",
|
||||||
|
Title: "Issue to rename",
|
||||||
|
Description: "Content",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
}
|
||||||
|
if err := store.CreateIssue(ctx, oldIssue, "test"); err != nil {
|
||||||
|
t.Fatalf("failed to create old issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a collision result with a rename
|
||||||
|
newIssue := &types.Issue{
|
||||||
|
ID: "bd-100",
|
||||||
|
Title: "Issue to rename",
|
||||||
|
Description: "Content",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
}
|
||||||
|
result := &CollisionResult{
|
||||||
|
Renames: []*RenameDetail{
|
||||||
|
{
|
||||||
|
OldID: "bd-1",
|
||||||
|
NewID: "bd-100",
|
||||||
|
Issue: newIssue,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the resolution
|
||||||
|
emptyMapping := make(map[string]string)
|
||||||
|
if err := ApplyCollisionResolution(ctx, store, result, emptyMapping); err != nil {
|
||||||
|
t.Fatalf("ApplyCollisionResolution failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify old issue was deleted
|
||||||
|
oldDeleted, err := store.GetIssue(ctx, "bd-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check old issue: %v", err)
|
||||||
|
}
|
||||||
|
if oldDeleted != nil {
|
||||||
|
t.Error("old issue bd-1 was not deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user