diff --git a/internal/storage/sqlite/collision.go b/internal/storage/sqlite/collision.go new file mode 100644 index 00000000..7fbce6e6 --- /dev/null +++ b/internal/storage/sqlite/collision.go @@ -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 +} diff --git a/internal/storage/sqlite/collision_test.go b/internal/storage/sqlite/collision_test.go new file mode 100644 index 00000000..0eeaa566 --- /dev/null +++ b/internal/storage/sqlite/collision_test.go @@ -0,0 +1,428 @@ +package sqlite + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +func TestDetectCollisions(t *testing.T) { + // Create temporary database + tmpDir, err := os.MkdirTemp("", "collision-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() + + // Setup: Create some existing issues in the database + existingIssue1 := &types.Issue{ + ID: "bd-1", + Title: "Existing issue 1", + Description: "This is an existing issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + + existingIssue2 := &types.Issue{ + ID: "bd-2", + Title: "Existing issue 2", + Description: "Another existing issue", + Status: types.StatusInProgress, + Priority: 2, + IssueType: types.TypeBug, + } + + if err := store.CreateIssue(ctx, existingIssue1, "test"); err != nil { + t.Fatalf("failed to create existing issue 1: %v", err) + } + if err := store.CreateIssue(ctx, existingIssue2, "test"); err != nil { + t.Fatalf("failed to create existing issue 2: %v", err) + } + + // Test cases + tests := []struct { + name string + incomingIssues []*types.Issue + expectedExact int + expectedCollision int + expectedNew int + checkCollisions func(t *testing.T, collisions []*CollisionDetail) + }{ + { + name: "exact match - idempotent import", + incomingIssues: []*types.Issue{ + { + ID: "bd-1", + Title: "Existing issue 1", + Description: "This is an existing issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + }, + }, + expectedExact: 1, + expectedCollision: 0, + expectedNew: 0, + }, + { + name: "new issue - doesn't exist in DB", + incomingIssues: []*types.Issue{ + { + ID: "bd-100", + Title: "Brand new issue", + Description: "This doesn't exist yet", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeFeature, + }, + }, + expectedExact: 0, + expectedCollision: 0, + expectedNew: 1, + }, + { + name: "collision - same ID, different title", + incomingIssues: []*types.Issue{ + { + ID: "bd-1", + Title: "Modified title", + Description: "This is an existing issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + }, + }, + expectedExact: 0, + expectedCollision: 1, + expectedNew: 0, + checkCollisions: func(t *testing.T, collisions []*CollisionDetail) { + if len(collisions) != 1 { + t.Fatalf("expected 1 collision, got %d", len(collisions)) + } + if collisions[0].ID != "bd-1" { + t.Errorf("expected collision ID bd-1, got %s", collisions[0].ID) + } + if len(collisions[0].ConflictingFields) != 1 { + t.Errorf("expected 1 conflicting field, got %d", len(collisions[0].ConflictingFields)) + } + if collisions[0].ConflictingFields[0] != "title" { + t.Errorf("expected conflicting field 'title', got %s", collisions[0].ConflictingFields[0]) + } + }, + }, + { + name: "collision - multiple fields differ", + incomingIssues: []*types.Issue{ + { + ID: "bd-2", + Title: "Changed title", + Description: "Changed description", + Status: types.StatusClosed, + Priority: 3, + IssueType: types.TypeFeature, + }, + }, + expectedExact: 0, + expectedCollision: 1, + expectedNew: 0, + checkCollisions: func(t *testing.T, collisions []*CollisionDetail) { + if len(collisions) != 1 { + t.Fatalf("expected 1 collision, got %d", len(collisions)) + } + // Should have multiple conflicting fields + expectedFields := map[string]bool{ + "title": true, + "description": true, + "status": true, + "priority": true, + "issue_type": true, + } + for _, field := range collisions[0].ConflictingFields { + if !expectedFields[field] { + t.Errorf("unexpected conflicting field: %s", field) + } + delete(expectedFields, field) + } + if len(expectedFields) > 0 { + t.Errorf("missing expected conflicting fields: %v", expectedFields) + } + }, + }, + { + name: "mixed - exact, collision, and new", + incomingIssues: []*types.Issue{ + { + // Exact match + ID: "bd-1", + Title: "Existing issue 1", + Description: "This is an existing issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + }, + { + // Collision + ID: "bd-2", + Title: "Modified issue 2", + Description: "Another existing issue", + Status: types.StatusInProgress, + Priority: 2, + IssueType: types.TypeBug, + }, + { + // New issue + ID: "bd-200", + Title: "New issue", + Description: "This is new", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + }, + }, + expectedExact: 1, + expectedCollision: 1, + expectedNew: 1, + }, + { + name: "collision - estimated_minutes differs", + incomingIssues: []*types.Issue{ + { + ID: "bd-1", + Title: "Existing issue 1", + Description: "This is an existing issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + EstimatedMinutes: intPtr(60), + }, + }, + expectedExact: 0, + expectedCollision: 1, + expectedNew: 0, + checkCollisions: func(t *testing.T, collisions []*CollisionDetail) { + if len(collisions[0].ConflictingFields) != 1 { + t.Errorf("expected 1 conflicting field, got %d", len(collisions[0].ConflictingFields)) + } + if collisions[0].ConflictingFields[0] != "estimated_minutes" { + t.Errorf("expected conflicting field 'estimated_minutes', got %s", collisions[0].ConflictingFields[0]) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := detectCollisions(ctx, store, tt.incomingIssues) + if err != nil { + t.Fatalf("detectCollisions failed: %v", err) + } + + if len(result.ExactMatches) != tt.expectedExact { + t.Errorf("expected %d exact matches, got %d", tt.expectedExact, len(result.ExactMatches)) + } + if len(result.Collisions) != tt.expectedCollision { + t.Errorf("expected %d collisions, got %d", tt.expectedCollision, len(result.Collisions)) + } + if len(result.NewIssues) != tt.expectedNew { + t.Errorf("expected %d new issues, got %d", tt.expectedNew, len(result.NewIssues)) + } + + if tt.checkCollisions != nil { + tt.checkCollisions(t, result.Collisions) + } + }) + } +} + +func TestCompareIssues(t *testing.T) { + tests := []struct { + name string + existing *types.Issue + incoming *types.Issue + expected []string + }{ + { + name: "identical issues", + existing: &types.Issue{ + ID: "bd-1", + Title: "Test", + Description: "Test desc", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + }, + incoming: &types.Issue{ + ID: "bd-1", + Title: "Test", + Description: "Test desc", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + }, + expected: []string{}, + }, + { + name: "different title", + existing: &types.Issue{ + ID: "bd-1", + Title: "Original", + Description: "Test", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + }, + incoming: &types.Issue{ + ID: "bd-1", + Title: "Modified", + Description: "Test", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + }, + expected: []string{"title"}, + }, + { + name: "different status and priority", + existing: &types.Issue{ + ID: "bd-1", + Title: "Test", + Description: "Test", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + }, + incoming: &types.Issue{ + ID: "bd-1", + Title: "Test", + Description: "Test", + Status: types.StatusClosed, + Priority: 3, + IssueType: types.TypeTask, + }, + expected: []string{"status", "priority"}, + }, + { + name: "estimated_minutes - both nil", + existing: &types.Issue{ + ID: "bd-1", + Title: "Test", + Description: "Test", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + EstimatedMinutes: nil, + }, + incoming: &types.Issue{ + ID: "bd-1", + Title: "Test", + Description: "Test", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + EstimatedMinutes: nil, + }, + expected: []string{}, + }, + { + name: "estimated_minutes - existing nil, incoming set", + existing: &types.Issue{ + ID: "bd-1", + Title: "Test", + Description: "Test", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + EstimatedMinutes: nil, + }, + incoming: &types.Issue{ + ID: "bd-1", + Title: "Test", + Description: "Test", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + EstimatedMinutes: intPtr(30), + }, + expected: []string{"estimated_minutes"}, + }, + { + name: "estimated_minutes - same values", + existing: &types.Issue{ + ID: "bd-1", + Title: "Test", + Description: "Test", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + EstimatedMinutes: intPtr(60), + }, + incoming: &types.Issue{ + ID: "bd-1", + Title: "Test", + Description: "Test", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + EstimatedMinutes: intPtr(60), + }, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conflicts := compareIssues(tt.existing, tt.incoming) + if len(conflicts) != len(tt.expected) { + t.Errorf("expected %d conflicts, got %d: %v", len(tt.expected), len(conflicts), conflicts) + return + } + for i, expected := range tt.expected { + if conflicts[i] != expected { + t.Errorf("conflict[%d]: expected %s, got %s", i, expected, conflicts[i]) + } + } + }) + } +} + +func TestEqualIntPtr(t *testing.T) { + tests := []struct { + name string + a *int + b *int + expected bool + }{ + {"both nil", nil, nil, true}, + {"a nil, b set", nil, intPtr(5), false}, + {"a set, b nil", intPtr(5), nil, false}, + {"same values", intPtr(10), intPtr(10), true}, + {"different values", intPtr(10), intPtr(20), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := equalIntPtr(tt.a, tt.b) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +// Helper function to create *int from int value +func intPtr(i int) *int { + return &i +}