package sqlite import ( "context" "crypto/sha256" "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 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 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 RemapIncoming bool // If true, remap incoming; if false, remap existing } // 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/update) - same ID, different fields // 3. New issue - ID doesn't exist in DB // 4. External ref match - Different ID but same external_ref (update from external system) // // When an incoming issue has an external_ref, we match by external_ref first, // then by ID. This enables re-syncing from external systems (Jira, GitHub, Linear). // // 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), } // Build content hash map for rename detection contentToID := make(map[string]string) for _, incoming := range incomingIssues { hash := hashIssueContent(incoming) contentToID[hash] = incoming.ID } // Check each incoming issue for _, incoming := range incomingIssues { var existing *types.Issue var err error // If incoming issue has external_ref, try matching by external_ref first if incoming.ExternalRef != nil && *incoming.ExternalRef != "" { existing, err = s.GetIssueByExternalRef(ctx, *incoming.ExternalRef) if err != nil { return nil, fmt.Errorf("failed to lookup by external_ref: %w", err) } } // If no external_ref match, try matching by ID if existing == nil { existing, err = s.GetIssue(ctx, incoming.ID) if err != nil { return nil, fmt.Errorf("failed to lookup by ID: %w", err) } } // No match found - it's a new issue if existing == nil { result.NewIssues = append(result.NewIssues, incoming.ID) continue } // Found a match - check if content matches conflictingFields := compareIssues(existing, incoming) if len(conflictingFields) == 0 { // Exact match - idempotent import result.ExactMatches = append(result.ExactMatches, incoming.ID) } else { // Same ID/external_ref, different content - collision (needs update) result.Collisions = append(result.Collisions, &CollisionDetail{ ID: incoming.ID, IncomingIssue: incoming, ExistingIssue: existing, ConflictingFields: conflictingFields, }) } } return result, nil } // compareIssues returns list of field names that differ between two issues func compareIssues(existing, incoming *types.Issue) []string { conflicts := []string{} if existing.Title != incoming.Title { conflicts = append(conflicts, "title") } if existing.Description != incoming.Description { conflicts = append(conflicts, "description") } 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") } 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.ExternalRef == nil && incoming.ExternalRef != nil) || (existing.ExternalRef != nil && incoming.ExternalRef == nil) || (existing.ExternalRef != nil && incoming.ExternalRef != nil && *existing.ExternalRef != *incoming.ExternalRef) { conflicts = append(conflicts, "external_ref") } return conflicts } // hashIssueContent creates a deterministic hash of issue content (excluding ID and timestamps) // // IMPORTANT: external_ref is included in the content hash. This means: // - Adding/removing/changing external_ref changes the content hash // - A local issue that gains an external_ref will have different content hash // - This is intentional: external_ref is semantically meaningful content // // Implications: // 1. Rename detection won't match issues before/after adding external_ref // 2. Content-based collision detection treats external_ref changes as updates // 3. Idempotent import only when external_ref is identical // // This design choice ensures external system linkage is tracked as substantive content, // not just metadata. See docs/HASH_ID_DESIGN.md for more on content hash philosophy. func hashIssueContent(issue *types.Issue) string { h := sha256.New() _, _ = fmt.Fprintf(h, "title:%s\n", issue.Title) _, _ = fmt.Fprintf(h, "description:%s\n", issue.Description) _, _ = fmt.Fprintf(h, "status:%s\n", issue.Status) _, _ = fmt.Fprintf(h, "priority:%d\n", issue.Priority) _, _ = fmt.Fprintf(h, "type:%s\n", issue.IssueType) _, _ = fmt.Fprintf(h, "assignee:%s\n", issue.Assignee) _, _ = fmt.Fprintf(h, "design:%s\n", issue.Design) _, _ = fmt.Fprintf(h, "acceptance:%s\n", issue.AcceptanceCriteria) _, _ = fmt.Fprintf(h, "notes:%s\n", issue.Notes) // external_ref is included in content hash (see comment above) if issue.ExternalRef != nil { _, _ = fmt.Fprintf(h, "external_ref:%s\n", *issue.ExternalRef) } return fmt.Sprintf("%x", h.Sum(nil)) }