Implement collision detection for import (bd-12)
Add collision detection infrastructure to identify conflicts during JSONL import when branches diverge and create issues with the same ID. New files: - internal/storage/sqlite/collision.go: Core collision detection logic - detectCollisions() categorizes issues as exact matches, collisions, or new - compareIssues() identifies which fields differ between issues - CollisionDetail provides detailed collision reporting - internal/storage/sqlite/collision_test.go: Comprehensive test suite - Tests exact matches, new issues, and various collision scenarios - Tests multi-field conflicts and edge cases - All tests passing This lays the foundation for bd-13 through bd-17 (reference scoring, ID remapping, CLI flags, and documentation). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
123
internal/storage/sqlite/collision.go
Normal file
123
internal/storage/sqlite/collision.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// CollisionResult categorizes incoming issues by their relationship to existing DB state
|
||||
type CollisionResult struct {
|
||||
ExactMatches []string // IDs that match exactly (idempotent import)
|
||||
Collisions []*CollisionDetail // Issues with same ID but different content
|
||||
NewIssues []string // IDs that don't exist in DB yet
|
||||
}
|
||||
|
||||
// CollisionDetail provides detailed information about a collision
|
||||
type CollisionDetail struct {
|
||||
ID string // The issue ID that collided
|
||||
IncomingIssue *types.Issue // The issue from the import file
|
||||
ExistingIssue *types.Issue // The issue currently in the database
|
||||
ConflictingFields []string // List of field names that differ
|
||||
}
|
||||
|
||||
// detectCollisions compares incoming JSONL issues against DB state
|
||||
// It distinguishes between:
|
||||
// 1. Exact match (idempotent) - ID and content are identical
|
||||
// 2. ID match but different content (collision) - same ID, different fields
|
||||
// 3. New issue - ID doesn't exist in DB
|
||||
//
|
||||
// Returns a CollisionResult categorizing all incoming issues.
|
||||
func detectCollisions(ctx context.Context, s *SQLiteStorage, incomingIssues []*types.Issue) (*CollisionResult, error) {
|
||||
result := &CollisionResult{
|
||||
ExactMatches: make([]string, 0),
|
||||
Collisions: make([]*CollisionDetail, 0),
|
||||
NewIssues: make([]string, 0),
|
||||
}
|
||||
|
||||
for _, incoming := range incomingIssues {
|
||||
// Check if issue exists in database
|
||||
existing, err := s.GetIssue(ctx, incoming.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check issue %s: %w", incoming.ID, err)
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
// Issue doesn't exist in DB - it's new
|
||||
result.NewIssues = append(result.NewIssues, incoming.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Issue exists - compare content
|
||||
conflicts := compareIssues(existing, incoming)
|
||||
if len(conflicts) == 0 {
|
||||
// No differences - exact match (idempotent)
|
||||
result.ExactMatches = append(result.ExactMatches, incoming.ID)
|
||||
} else {
|
||||
// Same ID but different content - collision
|
||||
result.Collisions = append(result.Collisions, &CollisionDetail{
|
||||
ID: incoming.ID,
|
||||
IncomingIssue: incoming,
|
||||
ExistingIssue: existing,
|
||||
ConflictingFields: conflicts,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// compareIssues compares two issues and returns a list of field names that differ
|
||||
// Timestamps (CreatedAt, UpdatedAt, ClosedAt) are intentionally not compared
|
||||
// Dependencies are also not compared (handled separately in import)
|
||||
func compareIssues(existing, incoming *types.Issue) []string {
|
||||
conflicts := make([]string, 0)
|
||||
|
||||
// Compare all relevant fields
|
||||
if existing.Title != incoming.Title {
|
||||
conflicts = append(conflicts, "title")
|
||||
}
|
||||
if existing.Description != incoming.Description {
|
||||
conflicts = append(conflicts, "description")
|
||||
}
|
||||
if existing.Design != incoming.Design {
|
||||
conflicts = append(conflicts, "design")
|
||||
}
|
||||
if existing.AcceptanceCriteria != incoming.AcceptanceCriteria {
|
||||
conflicts = append(conflicts, "acceptance_criteria")
|
||||
}
|
||||
if existing.Notes != incoming.Notes {
|
||||
conflicts = append(conflicts, "notes")
|
||||
}
|
||||
if existing.Status != incoming.Status {
|
||||
conflicts = append(conflicts, "status")
|
||||
}
|
||||
if existing.Priority != incoming.Priority {
|
||||
conflicts = append(conflicts, "priority")
|
||||
}
|
||||
if existing.IssueType != incoming.IssueType {
|
||||
conflicts = append(conflicts, "issue_type")
|
||||
}
|
||||
if existing.Assignee != incoming.Assignee {
|
||||
conflicts = append(conflicts, "assignee")
|
||||
}
|
||||
|
||||
// Compare EstimatedMinutes (handle nil cases)
|
||||
if !equalIntPtr(existing.EstimatedMinutes, incoming.EstimatedMinutes) {
|
||||
conflicts = append(conflicts, "estimated_minutes")
|
||||
}
|
||||
|
||||
return conflicts
|
||||
}
|
||||
|
||||
// equalIntPtr compares two *int pointers for equality
|
||||
func equalIntPtr(a, b *int) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
Reference in New Issue
Block a user