Add reference scoring to prioritize which colliding issues should be renumbered during collision resolution. Issues with fewer references are renumbered first to minimize total update work. Changes to collision.go: - Add ReferenceScore field to CollisionDetail - scoreCollisions() calculates scores and sorts collisions ascending - countReferences() counts text mentions + dependency references - Uses word-boundary regex (\b) to match exact IDs (bd-10 not bd-100) New tests in collision_test.go: - TestCountReferences: validates reference counting logic - TestScoreCollisions: verifies scoring and sorting behavior - TestCountReferencesWordBoundary: ensures exact ID matching Reference score = text mentions (desc/design/notes/criteria) + deps Sort order: fewest references first (minimizes renumbering impact) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
218 lines
6.7 KiB
Go
218 lines
6.7 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
|
|
"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
|
|
ReferenceScore int // Number of references to this issue (for scoring)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// scoreCollisions calculates reference scores for all colliding issues and sorts them
|
|
// by score ascending (fewest references first). This minimizes the total number of
|
|
// updates needed during renumbering - issues with fewer references are renumbered first.
|
|
//
|
|
// Reference score = text mentions + dependency references
|
|
func scoreCollisions(ctx context.Context, s *SQLiteStorage, collisions []*CollisionDetail, allIssues []*types.Issue) error {
|
|
// Build a map of all issues for quick lookup
|
|
issueMap := make(map[string]*types.Issue)
|
|
for _, issue := range allIssues {
|
|
issueMap[issue.ID] = issue
|
|
}
|
|
|
|
// Get all dependency records for efficient lookup
|
|
allDeps, err := s.GetAllDependencyRecords(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get dependency records: %w", err)
|
|
}
|
|
|
|
// Calculate reference score for each collision
|
|
for _, collision := range collisions {
|
|
score, err := countReferences(collision.ID, allIssues, allDeps)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to count references for %s: %w", collision.ID, err)
|
|
}
|
|
collision.ReferenceScore = score
|
|
}
|
|
|
|
// Sort collisions by reference score ascending (fewest first)
|
|
sort.Slice(collisions, func(i, j int) bool {
|
|
return collisions[i].ReferenceScore < collisions[j].ReferenceScore
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// countReferences counts how many times an issue ID is referenced
|
|
// Returns: text mentions + dependency references
|
|
func countReferences(issueID string, allIssues []*types.Issue, allDeps map[string][]*types.Dependency) (int, error) {
|
|
count := 0
|
|
|
|
// Count text mentions in all issues' text fields
|
|
// Use word boundary regex to match exact IDs (e.g., "bd-10" but not "bd-100")
|
|
pattern := fmt.Sprintf(`\b%s\b`, regexp.QuoteMeta(issueID))
|
|
re, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to compile regex for %s: %w", issueID, err)
|
|
}
|
|
|
|
for _, issue := range allIssues {
|
|
// Skip counting references in the issue itself
|
|
if issue.ID == issueID {
|
|
continue
|
|
}
|
|
|
|
// Count mentions in description
|
|
count += len(re.FindAllString(issue.Description, -1))
|
|
|
|
// Count mentions in design
|
|
count += len(re.FindAllString(issue.Design, -1))
|
|
|
|
// Count mentions in notes
|
|
count += len(re.FindAllString(issue.Notes, -1))
|
|
|
|
// Count mentions in acceptance criteria
|
|
count += len(re.FindAllString(issue.AcceptanceCriteria, -1))
|
|
}
|
|
|
|
// Count dependency references
|
|
// An issue can be referenced as either IssueID or DependsOnID
|
|
for _, deps := range allDeps {
|
|
for _, dep := range deps {
|
|
// Skip self-references
|
|
if dep.IssueID == issueID && dep.DependsOnID == issueID {
|
|
continue
|
|
}
|
|
|
|
// Count if this issue is the source (IssueID)
|
|
if dep.IssueID == issueID {
|
|
count++
|
|
}
|
|
|
|
// Count if this issue is the target (DependsOnID)
|
|
if dep.DependsOnID == issueID {
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
|
|
return count, nil
|
|
}
|