Fix bd-pq5k: merge conflicts now prefer closed>open and deletion>modification

CHANGES:
1. Merge logic (internal/merge/merge.go):
   - Added mergeStatus() enforcing closed ALWAYS wins over open
   - Fixed closed_at handling: only set when status='closed'
   - Changed deletion handling: deletion ALWAYS wins over modification

2. Deletion tracking (cmd/bd/snapshot_manager.go):
   - Updated ComputeAcceptedDeletions to accept all merge deletions
   - Removed "unchanged locally" check (deletion wins regardless)

3. FK constraint helper (internal/storage/sqlite/util.go):
   - Added IsForeignKeyConstraintError() for bd-koab
   - Detects FK violations for graceful import handling

TESTS UPDATED:
- TestMergeStatus: comprehensive status merge tests
- TestIsForeignKeyConstraintError: FK constraint detection
- bd-pq5k test: validates no invalid state (status=open with closed_at)
- Deletion tests: reflect new deletion-wins behavior
- All tests pass ✓

This ensures issues never get stuck in invalid states and prevents
the insane situation where issues never die!

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-11-23 21:42:43 -08:00
parent b428254f89
commit d4f9a05bb2
8 changed files with 926 additions and 55 deletions

View File

@@ -297,22 +297,14 @@ func merge3Way(base, left, right []Issue) ([]Issue, []string) {
}
} else if inBase && inLeft && !inRight {
// Deleted in right, maybe modified in left
if issuesEqual(baseIssue, leftIssue) {
// Deleted in right, unchanged in left - accept deletion
continue
} else {
// Modified in left, deleted in right - conflict
conflicts = append(conflicts, makeConflictWithBase(baseIssue.RawLine, leftIssue.RawLine, ""))
}
// RULE 2: deletion always wins over modification
// This is because deletion is an explicit action that should be preserved
continue
} else if inBase && !inLeft && inRight {
// Deleted in left, maybe modified in right
if issuesEqual(baseIssue, rightIssue) {
// Deleted in left, unchanged in right - accept deletion
continue
} else {
// Modified in right, deleted in left - conflict
conflicts = append(conflicts, makeConflictWithBase(baseIssue.RawLine, "", rightIssue.RawLine))
}
// RULE 2: deletion always wins over modification
// This is because deletion is an explicit action that should be preserved
continue
} else if !inBase && inLeft && !inRight {
// Added only in left
result = append(result, leftIssue)
@@ -341,8 +333,8 @@ func mergeIssue(base, left, right Issue) (Issue, string) {
// Merge notes
result.Notes = mergeField(base.Notes, left.Notes, right.Notes)
// Merge status
result.Status = mergeField(base.Status, left.Status, right.Status)
// Merge status - SPECIAL RULE: closed always wins over open
result.Status = mergeStatus(base.Status, left.Status, right.Status)
// Merge priority (as int)
if base.Priority == left.Priority && base.Priority != right.Priority {
@@ -362,8 +354,13 @@ func mergeIssue(base, left, right Issue) (Issue, string) {
// Merge updated_at - take the max
result.UpdatedAt = maxTime(left.UpdatedAt, right.UpdatedAt)
// Merge closed_at - take the max
result.ClosedAt = maxTime(left.ClosedAt, right.ClosedAt)
// Merge closed_at - only if status is closed
// This prevents invalid state (status=open with closed_at set)
if result.Status == "closed" {
result.ClosedAt = maxTime(left.ClosedAt, right.ClosedAt)
} else {
result.ClosedAt = ""
}
// Merge dependencies - combine and deduplicate
result.Dependencies = mergeDependencies(left.Dependencies, right.Dependencies)
@@ -376,6 +373,17 @@ func mergeIssue(base, left, right Issue) (Issue, string) {
return result, ""
}
func mergeStatus(base, left, right string) string {
// RULE 1: closed always wins over open
// This prevents the insane situation where issues never die
if left == "closed" || right == "closed" {
return "closed"
}
// Otherwise use standard 3-way merge
return mergeField(base, left, right)
}
func mergeField(base, left, right string) string {
if base == left && base != right {
return right

View File

@@ -7,6 +7,70 @@ import (
"testing"
)
// TestMergeStatus tests the status merging logic with special rules
func TestMergeStatus(t *testing.T) {
tests := []struct {
name string
base string
left string
right string
expected string
}{
{
name: "no changes",
base: "open",
left: "open",
right: "open",
expected: "open",
},
{
name: "left closed, right open - closed wins",
base: "open",
left: "closed",
right: "open",
expected: "closed",
},
{
name: "left open, right closed - closed wins",
base: "open",
left: "open",
right: "closed",
expected: "closed",
},
{
name: "both closed",
base: "open",
left: "closed",
right: "closed",
expected: "closed",
},
{
name: "base closed, left open, right open - open (standard merge)",
base: "closed",
left: "open",
right: "open",
expected: "open",
},
{
name: "base closed, left open, right closed - closed wins",
base: "closed",
left: "open",
right: "closed",
expected: "closed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := mergeStatus(tt.base, tt.left, tt.right)
if result != tt.expected {
t.Errorf("mergeStatus(%q, %q, %q) = %q, want %q",
tt.base, tt.left, tt.right, result, tt.expected)
}
})
}
}
// TestMergeField tests the basic field merging logic
func TestMergeField(t *testing.T) {
tests := []struct {
@@ -475,7 +539,7 @@ func TestMerge3Way_Deletions(t *testing.T) {
}
})
t.Run("deleted in left, modified in right - conflict", func(t *testing.T) {
t.Run("deleted in left, modified in right - deletion wins", func(t *testing.T) {
base := []Issue{
{
ID: "bd-abc123",
@@ -499,15 +563,15 @@ func TestMerge3Way_Deletions(t *testing.T) {
}
result, conflicts := merge3Way(base, left, right)
if len(conflicts) == 0 {
t.Error("expected conflict for delete vs modify")
if len(conflicts) != 0 {
t.Errorf("expected no conflicts, got %d", len(conflicts))
}
if len(result) != 0 {
t.Errorf("expected no merged issues with conflict, got %d", len(result))
t.Errorf("expected deletion to win (0 results), got %d", len(result))
}
})
t.Run("deleted in right, modified in left - conflict", func(t *testing.T) {
t.Run("deleted in right, modified in left - deletion wins", func(t *testing.T) {
base := []Issue{
{
ID: "bd-abc123",
@@ -531,11 +595,11 @@ func TestMerge3Way_Deletions(t *testing.T) {
right := []Issue{} // Deleted in right
result, conflicts := merge3Way(base, left, right)
if len(conflicts) == 0 {
t.Error("expected conflict for modify vs delete")
if len(conflicts) != 0 {
t.Errorf("expected no conflicts, got %d", len(conflicts))
}
if len(result) != 0 {
t.Errorf("expected no merged issues with conflict, got %d", len(result))
t.Errorf("expected deletion to win (0 results), got %d", len(result))
}
})
}
@@ -648,6 +712,61 @@ func TestMerge3Way_Additions(t *testing.T) {
// TestMerge3Way_ResurrectionPrevention tests bd-hv01 regression
func TestMerge3Way_ResurrectionPrevention(t *testing.T) {
t.Run("bd-pq5k: no invalid state (status=open with closed_at)", func(t *testing.T) {
// Simulate the broken merge case that was creating invalid data
// Base: issue is closed
base := []Issue{
{
ID: "bd-test",
Title: "Test issue",
Status: "closed",
ClosedAt: "2024-01-02T00:00:00Z",
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-02T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-test","title":"Test issue","status":"closed","closed_at":"2024-01-02T00:00:00Z","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T00:00:00Z","created_by":"user1"}`,
},
}
// Left: still closed with closed_at
left := base
// Right: somehow got reopened but WITHOUT removing closed_at (the bug scenario)
right := []Issue{
{
ID: "bd-test",
Title: "Test issue",
Status: "open", // reopened
ClosedAt: "", // correctly removed
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-03T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-test","title":"Test issue","status":"open","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-03T00:00:00Z","created_by":"user1"}`,
},
}
result, conflicts := merge3Way(base, left, right)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
// CRITICAL: Status should be closed (closed wins over open)
if result[0].Status != "closed" {
t.Errorf("expected status 'closed', got %q", result[0].Status)
}
// CRITICAL: If status is closed, closed_at MUST be set
if result[0].Status == "closed" && result[0].ClosedAt == "" {
t.Error("INVALID STATE: status='closed' but closed_at is empty")
}
// CRITICAL: If status is open, closed_at MUST be empty
if result[0].Status == "open" && result[0].ClosedAt != "" {
t.Errorf("INVALID STATE: status='open' but closed_at='%s'", result[0].ClosedAt)
}
})
t.Run("bd-hv01 regression: closed issue not resurrected", func(t *testing.T) {
// Base: issue is open
base := []Issue{

View File

@@ -51,4 +51,15 @@ func IsUniqueConstraintError(err error) bool {
return strings.Contains(err.Error(), "UNIQUE constraint failed")
}
// IsForeignKeyConstraintError checks if an error is a FOREIGN KEY constraint violation
// This can occur when importing issues that reference deleted issues (e.g., after merge)
func IsForeignKeyConstraintError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return strings.Contains(errStr, "FOREIGN KEY constraint failed") ||
strings.Contains(errStr, "foreign key constraint failed")
}

View File

@@ -50,6 +50,54 @@ func TestIsUniqueConstraintError(t *testing.T) {
}
}
func TestIsForeignKeyConstraintError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{
name: "nil error",
err: nil,
expected: false,
},
{
name: "FOREIGN KEY constraint error (uppercase)",
err: errors.New("FOREIGN KEY constraint failed"),
expected: true,
},
{
name: "foreign key constraint error (lowercase)",
err: errors.New("foreign key constraint failed"),
expected: true,
},
{
name: "FOREIGN KEY with details",
err: errors.New("FOREIGN KEY constraint failed: dependencies.depends_on_id"),
expected: true,
},
{
name: "UNIQUE constraint error",
err: errors.New("UNIQUE constraint failed: issues.id"),
expected: false,
},
{
name: "other error",
err: errors.New("some other database error"),
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsForeignKeyConstraintError(tt.err)
if result != tt.expected {
t.Errorf("IsForeignKeyConstraintError(%v) = %v, want %v", tt.err, result, tt.expected)
}
})
}
}
func TestExecInTransaction(t *testing.T) {
ctx := context.Background()
store := newTestStore(t, t.TempDir()+"/test.db")