- bd-ki14: Preserve tombstones when other side implicitly deleted In merge3WayWithTTL(), implicit deletion cases now check if the remaining side is a tombstone and preserve it instead of dropping. - bd-ig5: Remove duplicate constants from merge package StatusTombstone, DefaultTombstoneTTL, and ClockSkewGrace now reference the types package to avoid duplication. - bd-6x5: Handle empty DeletedAt in mergeTombstones() Added explicit handling for edge cases where one or both tombstones have empty DeletedAt fields with deterministic behavior. - bd-1sn: Copy tombstone fields in mergeIssue() safety fallback When status becomes tombstone via mergeStatus safety fallback, tombstone fields are now copied from the appropriate side. Added comprehensive tests for all fixed edge cases. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1908 lines
54 KiB
Go
1908 lines
54 KiB
Go
package merge
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// 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 {
|
|
name string
|
|
base string
|
|
left string
|
|
right string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "no changes",
|
|
base: "original",
|
|
left: "original",
|
|
right: "original",
|
|
expected: "original",
|
|
},
|
|
{
|
|
name: "left changed",
|
|
base: "original",
|
|
left: "left-changed",
|
|
right: "original",
|
|
expected: "left-changed",
|
|
},
|
|
{
|
|
name: "right changed",
|
|
base: "original",
|
|
left: "original",
|
|
right: "right-changed",
|
|
expected: "right-changed",
|
|
},
|
|
{
|
|
name: "both changed to same value",
|
|
base: "original",
|
|
left: "both-changed",
|
|
right: "both-changed",
|
|
expected: "both-changed",
|
|
},
|
|
{
|
|
name: "both changed to different values - prefers left",
|
|
base: "original",
|
|
left: "left-value",
|
|
right: "right-value",
|
|
expected: "left-value",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := mergeField(tt.base, tt.left, tt.right)
|
|
if result != tt.expected {
|
|
t.Errorf("mergeField() = %q, want %q", result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMergeDependencies tests dependency union and deduplication
|
|
func TestMergeDependencies(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
left []Dependency
|
|
right []Dependency
|
|
expected []Dependency
|
|
}{
|
|
{
|
|
name: "empty both sides",
|
|
left: []Dependency{},
|
|
right: []Dependency{},
|
|
expected: []Dependency{},
|
|
},
|
|
{
|
|
name: "only left has deps",
|
|
left: []Dependency{
|
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
},
|
|
right: []Dependency{},
|
|
expected: []Dependency{
|
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
},
|
|
},
|
|
{
|
|
name: "only right has deps",
|
|
left: []Dependency{},
|
|
right: []Dependency{
|
|
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
},
|
|
expected: []Dependency{
|
|
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
},
|
|
},
|
|
{
|
|
name: "union of different deps",
|
|
left: []Dependency{
|
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
},
|
|
right: []Dependency{
|
|
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
},
|
|
expected: []Dependency{
|
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
},
|
|
},
|
|
{
|
|
name: "deduplication of identical deps",
|
|
left: []Dependency{
|
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
},
|
|
right: []Dependency{
|
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-02T00:00:00Z"}, // Different timestamp but same logical dep
|
|
},
|
|
expected: []Dependency{
|
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
},
|
|
},
|
|
{
|
|
name: "multiple deps with dedup",
|
|
left: []Dependency{
|
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
},
|
|
right: []Dependency{
|
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-02T00:00:00Z"},
|
|
{IssueID: "bd-1", DependsOnID: "bd-4", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
},
|
|
expected: []Dependency{
|
|
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
{IssueID: "bd-1", DependsOnID: "bd-4", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := mergeDependencies(tt.left, tt.right)
|
|
if len(result) != len(tt.expected) {
|
|
t.Errorf("mergeDependencies() returned %d deps, want %d", len(result), len(tt.expected))
|
|
return
|
|
}
|
|
// Check each expected dep is present
|
|
for _, exp := range tt.expected {
|
|
found := false
|
|
for _, res := range result {
|
|
if res.IssueID == exp.IssueID &&
|
|
res.DependsOnID == exp.DependsOnID &&
|
|
res.Type == exp.Type {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected dependency %+v not found in result", exp)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMaxTime tests timestamp merging (max wins)
|
|
func TestMaxTime(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
t1 string
|
|
t2 string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "both empty",
|
|
t1: "",
|
|
t2: "",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "t1 empty",
|
|
t1: "",
|
|
t2: "2024-01-02T00:00:00Z",
|
|
expected: "2024-01-02T00:00:00Z",
|
|
},
|
|
{
|
|
name: "t2 empty",
|
|
t1: "2024-01-01T00:00:00Z",
|
|
t2: "",
|
|
expected: "2024-01-01T00:00:00Z",
|
|
},
|
|
{
|
|
name: "t1 newer",
|
|
t1: "2024-01-02T00:00:00Z",
|
|
t2: "2024-01-01T00:00:00Z",
|
|
expected: "2024-01-02T00:00:00Z",
|
|
},
|
|
{
|
|
name: "t2 newer",
|
|
t1: "2024-01-01T00:00:00Z",
|
|
t2: "2024-01-02T00:00:00Z",
|
|
expected: "2024-01-02T00:00:00Z",
|
|
},
|
|
{
|
|
name: "identical timestamps",
|
|
t1: "2024-01-01T00:00:00Z",
|
|
t2: "2024-01-01T00:00:00Z",
|
|
expected: "2024-01-01T00:00:00Z",
|
|
},
|
|
{
|
|
name: "with fractional seconds (RFC3339Nano)",
|
|
t1: "2024-01-01T00:00:00.123456Z",
|
|
t2: "2024-01-01T00:00:00.123455Z",
|
|
expected: "2024-01-01T00:00:00.123456Z",
|
|
},
|
|
{
|
|
name: "invalid timestamps - returns t2 as fallback",
|
|
t1: "invalid",
|
|
t2: "also-invalid",
|
|
expected: "also-invalid",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := maxTime(tt.t1, tt.t2)
|
|
if result != tt.expected {
|
|
t.Errorf("maxTime() = %q, want %q", result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIsTimeAfter tests timestamp comparison including error handling
|
|
func TestIsTimeAfter(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
t1 string
|
|
t2 string
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "both empty - prefer left",
|
|
t1: "",
|
|
t2: "",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "t1 empty - t2 wins",
|
|
t1: "",
|
|
t2: "2024-01-02T00:00:00Z",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "t2 empty - t1 wins",
|
|
t1: "2024-01-01T00:00:00Z",
|
|
t2: "",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "t1 newer",
|
|
t1: "2024-01-02T00:00:00Z",
|
|
t2: "2024-01-01T00:00:00Z",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "t2 newer",
|
|
t1: "2024-01-01T00:00:00Z",
|
|
t2: "2024-01-02T00:00:00Z",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "identical timestamps - left wins (bd-8nz)",
|
|
t1: "2024-01-01T00:00:00Z",
|
|
t2: "2024-01-01T00:00:00Z",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "t1 invalid, t2 valid - t2 wins",
|
|
t1: "not-a-timestamp",
|
|
t2: "2024-01-01T00:00:00Z",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "t1 valid, t2 invalid - t1 wins",
|
|
t1: "2024-01-01T00:00:00Z",
|
|
t2: "not-a-timestamp",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "both invalid - prefer left",
|
|
t1: "invalid1",
|
|
t2: "invalid2",
|
|
expected: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := isTimeAfter(tt.t1, tt.t2)
|
|
if result != tt.expected {
|
|
t.Errorf("isTimeAfter(%q, %q) = %v, want %v", tt.t1, tt.t2, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMerge3Way_SimpleUpdates tests simple field update scenarios
|
|
func TestMerge3Way_SimpleUpdates(t *testing.T) {
|
|
base := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "Original title",
|
|
Status: "open",
|
|
Priority: 2,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
UpdatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"Original title","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
|
|
t.Run("left updates title", func(t *testing.T) {
|
|
left := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "Updated title",
|
|
Status: "open",
|
|
Priority: 2,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
UpdatedAt: "2024-01-02T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"Updated title","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
right := base
|
|
|
|
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))
|
|
}
|
|
if result[0].Title != "Updated title" {
|
|
t.Errorf("expected title 'Updated title', got %q", result[0].Title)
|
|
}
|
|
})
|
|
|
|
t.Run("right updates status", func(t *testing.T) {
|
|
left := base
|
|
right := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "Original title",
|
|
Status: "in_progress",
|
|
Priority: 2,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
UpdatedAt: "2024-01-02T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"Original title","status":"in_progress","priority":2,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T00: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))
|
|
}
|
|
if result[0].Status != "in_progress" {
|
|
t.Errorf("expected status 'in_progress', got %q", result[0].Status)
|
|
}
|
|
})
|
|
|
|
t.Run("both update different fields", func(t *testing.T) {
|
|
left := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "Updated title",
|
|
Status: "open",
|
|
Priority: 2,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
UpdatedAt: "2024-01-02T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"Updated title","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
right := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "Original title",
|
|
Status: "in_progress",
|
|
Priority: 2,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
UpdatedAt: "2024-01-02T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"Original title","status":"in_progress","priority":2,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T00: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))
|
|
}
|
|
if result[0].Title != "Updated title" {
|
|
t.Errorf("expected title 'Updated title', got %q", result[0].Title)
|
|
}
|
|
if result[0].Status != "in_progress" {
|
|
t.Errorf("expected status 'in_progress', got %q", result[0].Status)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMergePriority tests priority merging including bd-d0t fix
|
|
func TestMergePriority(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
base int
|
|
left int
|
|
right int
|
|
expected int
|
|
}{
|
|
{
|
|
name: "no changes",
|
|
base: 2,
|
|
left: 2,
|
|
right: 2,
|
|
expected: 2,
|
|
},
|
|
{
|
|
name: "left changed",
|
|
base: 2,
|
|
left: 1,
|
|
right: 2,
|
|
expected: 1,
|
|
},
|
|
{
|
|
name: "right changed",
|
|
base: 2,
|
|
left: 2,
|
|
right: 3,
|
|
expected: 3,
|
|
},
|
|
{
|
|
name: "both changed to same value",
|
|
base: 2,
|
|
left: 1,
|
|
right: 1,
|
|
expected: 1,
|
|
},
|
|
{
|
|
name: "conflict - higher priority wins (lower number)",
|
|
base: 2,
|
|
left: 3,
|
|
right: 1,
|
|
expected: 1,
|
|
},
|
|
// bd-d0t fix: 0 is treated as "unset"
|
|
{
|
|
name: "bd-d0t: left unset (0), right has explicit priority",
|
|
base: 2,
|
|
left: 0,
|
|
right: 3,
|
|
expected: 3, // explicit priority wins over unset
|
|
},
|
|
{
|
|
name: "bd-d0t: left has explicit priority, right unset (0)",
|
|
base: 2,
|
|
left: 3,
|
|
right: 0,
|
|
expected: 3, // explicit priority wins over unset
|
|
},
|
|
{
|
|
name: "bd-d0t: both unset (0)",
|
|
base: 2,
|
|
left: 0,
|
|
right: 0,
|
|
expected: 0,
|
|
},
|
|
{
|
|
name: "bd-d0t: base unset, left sets priority, right unchanged",
|
|
base: 0,
|
|
left: 1,
|
|
right: 0,
|
|
expected: 1, // left changed from 0 to 1
|
|
},
|
|
{
|
|
name: "bd-d0t: base unset, right sets priority, left unchanged",
|
|
base: 0,
|
|
left: 0,
|
|
right: 2,
|
|
expected: 2, // right changed from 0 to 2
|
|
},
|
|
// bd-1kf fix: negative priorities should be handled consistently
|
|
{
|
|
name: "bd-1kf: negative priority should win over unset (0)",
|
|
base: 2,
|
|
left: 0,
|
|
right: -1,
|
|
expected: -1, // negative priority is explicit, should win over unset
|
|
},
|
|
{
|
|
name: "bd-1kf: negative priority on left should win over unset (0) on right",
|
|
base: 2,
|
|
left: -1,
|
|
right: 0,
|
|
expected: -1, // negative priority is explicit, should win over unset
|
|
},
|
|
{
|
|
name: "bd-1kf: conflict between negative priorities - lower wins",
|
|
base: 2,
|
|
left: -2,
|
|
right: -1,
|
|
expected: -2, // -2 is higher priority (more urgent) than -1
|
|
},
|
|
{
|
|
name: "bd-1kf: negative vs positive priority conflict",
|
|
base: 2,
|
|
left: -1,
|
|
right: 1,
|
|
expected: -1, // -1 is higher priority (lower number) than 1
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := mergePriority(tt.base, tt.left, tt.right)
|
|
if result != tt.expected {
|
|
t.Errorf("mergePriority(%d, %d, %d) = %d, want %d",
|
|
tt.base, tt.left, tt.right, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMerge3Way_AutoResolve tests auto-resolution of conflicts
|
|
func TestMerge3Way_AutoResolve(t *testing.T) {
|
|
t.Run("conflicting title changes - latest updated_at wins", func(t *testing.T) {
|
|
base := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "Original",
|
|
UpdatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"Original","updated_at":"2024-01-01T00:00:00Z","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
left := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "Left version",
|
|
UpdatedAt: "2024-01-02T00:00:00Z", // Older
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"Left version","updated_at":"2024-01-02T00:00:00Z","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
right := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "Right version",
|
|
UpdatedAt: "2024-01-03T00:00:00Z", // Newer - this should win
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"Right version","updated_at":"2024-01-03T00:00:00Z","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
|
|
result, conflicts := merge3Way(base, left, right)
|
|
if len(conflicts) != 0 {
|
|
t.Errorf("expected no conflicts with auto-resolution, got %d", len(conflicts))
|
|
}
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 merged issue, got %d", len(result))
|
|
}
|
|
// Right has newer updated_at, so right's title wins
|
|
if result[0].Title != "Right version" {
|
|
t.Errorf("expected title 'Right version' (newer updated_at), got %q", result[0].Title)
|
|
}
|
|
})
|
|
|
|
t.Run("conflicting priority changes - higher priority wins (lower number)", func(t *testing.T) {
|
|
base := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Priority: 2,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","priority":2,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
left := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Priority: 3, // Lower priority (higher number)
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","priority":3,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
right := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Priority: 1, // Higher priority (lower number) - this should win
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","priority":1,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
|
|
result, conflicts := merge3Way(base, left, right)
|
|
if len(conflicts) != 0 {
|
|
t.Errorf("expected no conflicts with auto-resolution, got %d", len(conflicts))
|
|
}
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 merged issue, got %d", len(result))
|
|
}
|
|
// Lower priority number wins
|
|
if result[0].Priority != 1 {
|
|
t.Errorf("expected priority 1 (higher priority), got %d", result[0].Priority)
|
|
}
|
|
})
|
|
|
|
t.Run("conflicting notes - concatenated", func(t *testing.T) {
|
|
base := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Notes: "Original notes",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","notes":"Original notes","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
left := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Notes: "Left notes",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","notes":"Left notes","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
right := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Notes: "Right notes",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","notes":"Right notes","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
|
|
result, conflicts := merge3Way(base, left, right)
|
|
if len(conflicts) != 0 {
|
|
t.Errorf("expected no conflicts with auto-resolution, got %d", len(conflicts))
|
|
}
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 merged issue, got %d", len(result))
|
|
}
|
|
// Notes should be concatenated
|
|
expectedNotes := "Left notes\n\n---\n\nRight notes"
|
|
if result[0].Notes != expectedNotes {
|
|
t.Errorf("expected notes %q, got %q", expectedNotes, result[0].Notes)
|
|
}
|
|
})
|
|
|
|
t.Run("conflicting issue_type - local (left) wins", func(t *testing.T) {
|
|
base := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
IssueType: "task",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","issue_type":"task","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
left := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
IssueType: "bug", // Local change - should win
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","issue_type":"bug","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
right := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
IssueType: "feature",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","issue_type":"feature","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
|
|
result, conflicts := merge3Way(base, left, right)
|
|
if len(conflicts) != 0 {
|
|
t.Errorf("expected no conflicts with auto-resolution, got %d", len(conflicts))
|
|
}
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 merged issue, got %d", len(result))
|
|
}
|
|
// Local (left) wins for issue_type
|
|
if result[0].IssueType != "bug" {
|
|
t.Errorf("expected issue_type 'bug' (local wins), got %q", result[0].IssueType)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMerge3Way_Deletions tests deletion detection scenarios
|
|
func TestMerge3Way_Deletions(t *testing.T) {
|
|
t.Run("deleted in left, unchanged in right", func(t *testing.T) {
|
|
base := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "Will be deleted",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"Will be deleted","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
left := []Issue{} // Deleted in left
|
|
right := base // Unchanged in right
|
|
|
|
result, conflicts := merge3Way(base, left, right)
|
|
if len(conflicts) != 0 {
|
|
t.Errorf("unexpected conflicts: %v", conflicts)
|
|
}
|
|
if len(result) != 0 {
|
|
t.Errorf("expected deletion to be accepted, got %d issues", len(result))
|
|
}
|
|
})
|
|
|
|
t.Run("deleted in right, unchanged in left", func(t *testing.T) {
|
|
base := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "Will be deleted",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"Will be deleted","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
left := base // Unchanged in left
|
|
right := []Issue{} // Deleted in right
|
|
|
|
result, conflicts := merge3Way(base, left, right)
|
|
if len(conflicts) != 0 {
|
|
t.Errorf("unexpected conflicts: %v", conflicts)
|
|
}
|
|
if len(result) != 0 {
|
|
t.Errorf("expected deletion to be accepted, got %d issues", len(result))
|
|
}
|
|
})
|
|
|
|
t.Run("deleted in left, modified in right - deletion wins", func(t *testing.T) {
|
|
base := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "Original",
|
|
Status: "open",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"Original","status":"open","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
left := []Issue{} // Deleted in left
|
|
right := []Issue{ // Modified in right
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "Modified",
|
|
Status: "in_progress",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"Modified","status":"in_progress","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
|
|
result, conflicts := merge3Way(base, left, right)
|
|
if len(conflicts) != 0 {
|
|
t.Errorf("expected no conflicts, got %d", len(conflicts))
|
|
}
|
|
if len(result) != 0 {
|
|
t.Errorf("expected deletion to win (0 results), got %d", len(result))
|
|
}
|
|
})
|
|
|
|
t.Run("deleted in right, modified in left - deletion wins", func(t *testing.T) {
|
|
base := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "Original",
|
|
Status: "open",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"Original","status":"open","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
left := []Issue{ // Modified in left
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "Modified",
|
|
Status: "in_progress",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"Modified","status":"in_progress","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
right := []Issue{} // Deleted in right
|
|
|
|
result, conflicts := merge3Way(base, left, right)
|
|
if len(conflicts) != 0 {
|
|
t.Errorf("expected no conflicts, got %d", len(conflicts))
|
|
}
|
|
if len(result) != 0 {
|
|
t.Errorf("expected deletion to win (0 results), got %d", len(result))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMerge3Way_Additions tests issue addition scenarios
|
|
func TestMerge3Way_Additions(t *testing.T) {
|
|
t.Run("added only in left", func(t *testing.T) {
|
|
base := []Issue{}
|
|
left := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "New issue",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"New issue","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
right := []Issue{}
|
|
|
|
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))
|
|
}
|
|
if result[0].Title != "New issue" {
|
|
t.Errorf("expected title 'New issue', got %q", result[0].Title)
|
|
}
|
|
})
|
|
|
|
t.Run("added only in right", func(t *testing.T) {
|
|
base := []Issue{}
|
|
left := []Issue{}
|
|
right := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "New issue",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"New issue","created_at":"2024-01-01T00: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))
|
|
}
|
|
if result[0].Title != "New issue" {
|
|
t.Errorf("expected title 'New issue', got %q", result[0].Title)
|
|
}
|
|
})
|
|
|
|
t.Run("added in both with identical content", func(t *testing.T) {
|
|
base := []Issue{}
|
|
issueData := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "New issue",
|
|
Status: "open",
|
|
Priority: 2,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"New issue","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
}
|
|
left := []Issue{issueData}
|
|
right := []Issue{issueData}
|
|
|
|
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))
|
|
}
|
|
})
|
|
|
|
t.Run("added in both with different content - auto-resolved", func(t *testing.T) {
|
|
base := []Issue{}
|
|
left := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "Left version",
|
|
UpdatedAt: "2024-01-02T00:00:00Z", // Older
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"Left version","updated_at":"2024-01-02T00:00:00Z","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
right := []Issue{
|
|
{
|
|
ID: "bd-abc123",
|
|
Title: "Right version",
|
|
UpdatedAt: "2024-01-03T00:00:00Z", // Newer - should win
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-abc123","title":"Right version","updated_at":"2024-01-03T00:00:00Z","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
|
|
result, conflicts := merge3Way(base, left, right)
|
|
if len(conflicts) != 0 {
|
|
t.Errorf("expected no conflicts with auto-resolution, got %d", len(conflicts))
|
|
}
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 merged issue, got %d", len(result))
|
|
}
|
|
// Right has newer updated_at, so right's title wins
|
|
if result[0].Title != "Right version" {
|
|
t.Errorf("expected title 'Right version' (newer updated_at), got %q", result[0].Title)
|
|
}
|
|
})
|
|
}
|
|
|
|
// 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{
|
|
{
|
|
ID: "bd-hv01",
|
|
Title: "Test issue",
|
|
Status: "open",
|
|
ClosedAt: "",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
UpdatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
RawLine: `{"id":"bd-hv01","title":"Test issue","status":"open","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
|
},
|
|
}
|
|
// Left: issue is closed (newer)
|
|
left := []Issue{
|
|
{
|
|
ID: "bd-hv01",
|
|
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-hv01","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"}`,
|
|
},
|
|
}
|
|
// Right: issue is still open (stale)
|
|
right := base
|
|
|
|
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))
|
|
}
|
|
// Issue should remain closed (left's version)
|
|
if result[0].Status != "closed" {
|
|
t.Errorf("expected status 'closed', got %q - issue was resurrected!", result[0].Status)
|
|
}
|
|
if result[0].ClosedAt == "" {
|
|
t.Error("expected closed_at to be set, got empty string")
|
|
}
|
|
// UpdatedAt should be the max (left's newer timestamp)
|
|
if result[0].UpdatedAt != "2024-01-02T00:00:00Z" {
|
|
t.Errorf("expected updated_at '2024-01-02T00:00:00Z', got %q", result[0].UpdatedAt)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMerge3Way_Integration tests full merge scenarios with file I/O
|
|
func TestMerge3Way_Integration(t *testing.T) {
|
|
t.Run("full merge workflow", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create test files
|
|
baseFile := filepath.Join(tmpDir, "base.jsonl")
|
|
leftFile := filepath.Join(tmpDir, "left.jsonl")
|
|
rightFile := filepath.Join(tmpDir, "right.jsonl")
|
|
outputFile := filepath.Join(tmpDir, "output.jsonl")
|
|
|
|
// Base: two issues
|
|
baseData := `{"id":"bd-1","title":"Issue 1","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}
|
|
{"id":"bd-2","title":"Issue 2","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}
|
|
`
|
|
if err := os.WriteFile(baseFile, []byte(baseData), 0644); err != nil {
|
|
t.Fatalf("failed to write base file: %v", err)
|
|
}
|
|
|
|
// Left: update bd-1 title, add bd-3
|
|
leftData := `{"id":"bd-1","title":"Updated Issue 1","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T00:00:00Z","created_by":"user1"}
|
|
{"id":"bd-2","title":"Issue 2","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}
|
|
{"id":"bd-3","title":"New Issue 3","status":"open","priority":1,"created_at":"2024-01-02T00:00:00Z","created_by":"user1"}
|
|
`
|
|
if err := os.WriteFile(leftFile, []byte(leftData), 0644); err != nil {
|
|
t.Fatalf("failed to write left file: %v", err)
|
|
}
|
|
|
|
// Right: update bd-2 status, add bd-4
|
|
rightData := `{"id":"bd-1","title":"Issue 1","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}
|
|
{"id":"bd-2","title":"Issue 2","status":"in_progress","priority":2,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T00:00:00Z","created_by":"user1"}
|
|
{"id":"bd-4","title":"New Issue 4","status":"open","priority":3,"created_at":"2024-01-02T00:00:00Z","created_by":"user1"}
|
|
`
|
|
if err := os.WriteFile(rightFile, []byte(rightData), 0644); err != nil {
|
|
t.Fatalf("failed to write right file: %v", err)
|
|
}
|
|
|
|
// Perform merge
|
|
err := Merge3Way(outputFile, baseFile, leftFile, rightFile, false)
|
|
if err != nil {
|
|
t.Fatalf("merge failed: %v", err)
|
|
}
|
|
|
|
// Read result
|
|
content, err := os.ReadFile(outputFile)
|
|
if err != nil {
|
|
t.Fatalf("failed to read output file: %v", err)
|
|
}
|
|
|
|
// Parse result
|
|
var results []Issue
|
|
for _, line := range splitLines(string(content)) {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var issue Issue
|
|
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
|
t.Fatalf("failed to parse output line: %v", err)
|
|
}
|
|
results = append(results, issue)
|
|
}
|
|
|
|
// Should have 4 issues: bd-1 (updated), bd-2 (updated), bd-3 (new), bd-4 (new)
|
|
if len(results) != 4 {
|
|
t.Fatalf("expected 4 issues, got %d", len(results))
|
|
}
|
|
|
|
// Verify bd-1 has updated title from left
|
|
found1 := false
|
|
for _, issue := range results {
|
|
if issue.ID == "bd-1" {
|
|
found1 = true
|
|
if issue.Title != "Updated Issue 1" {
|
|
t.Errorf("bd-1 title: expected 'Updated Issue 1', got %q", issue.Title)
|
|
}
|
|
}
|
|
}
|
|
if !found1 {
|
|
t.Error("bd-1 not found in results")
|
|
}
|
|
|
|
// Verify bd-2 has updated status from right
|
|
found2 := false
|
|
for _, issue := range results {
|
|
if issue.ID == "bd-2" {
|
|
found2 = true
|
|
if issue.Status != "in_progress" {
|
|
t.Errorf("bd-2 status: expected 'in_progress', got %q", issue.Status)
|
|
}
|
|
}
|
|
}
|
|
if !found2 {
|
|
t.Error("bd-2 not found in results")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestIsTombstone tests the tombstone detection helper
|
|
func TestIsTombstone(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status string
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "tombstone status",
|
|
status: "tombstone",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "open status",
|
|
status: "open",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "closed status",
|
|
status: "closed",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "in_progress status",
|
|
status: "in_progress",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "empty status",
|
|
status: "",
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
issue := Issue{Status: tt.status}
|
|
result := IsTombstone(issue)
|
|
if result != tt.expected {
|
|
t.Errorf("IsTombstone() = %v, want %v", result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMergeTombstones tests merging two tombstones
|
|
func TestMergeTombstones(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
leftDeletedAt string
|
|
rightDeletedAt string
|
|
expectedSide string // "left" or "right"
|
|
}{
|
|
{
|
|
name: "left deleted later",
|
|
leftDeletedAt: "2024-01-02T00:00:00Z",
|
|
rightDeletedAt: "2024-01-01T00:00:00Z",
|
|
expectedSide: "left",
|
|
},
|
|
{
|
|
name: "right deleted later",
|
|
leftDeletedAt: "2024-01-01T00:00:00Z",
|
|
rightDeletedAt: "2024-01-02T00:00:00Z",
|
|
expectedSide: "right",
|
|
},
|
|
{
|
|
name: "same timestamp - left wins (tie breaker)",
|
|
leftDeletedAt: "2024-01-01T00:00:00Z",
|
|
rightDeletedAt: "2024-01-01T00:00:00Z",
|
|
expectedSide: "left",
|
|
},
|
|
{
|
|
name: "with fractional seconds",
|
|
leftDeletedAt: "2024-01-01T00:00:00.123456Z",
|
|
rightDeletedAt: "2024-01-01T00:00:00.123455Z",
|
|
expectedSide: "left",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
left := Issue{
|
|
ID: "bd-test",
|
|
Status: StatusTombstone,
|
|
DeletedAt: tt.leftDeletedAt,
|
|
DeletedBy: "user-left",
|
|
}
|
|
right := Issue{
|
|
ID: "bd-test",
|
|
Status: StatusTombstone,
|
|
DeletedAt: tt.rightDeletedAt,
|
|
DeletedBy: "user-right",
|
|
}
|
|
result := mergeTombstones(left, right)
|
|
if tt.expectedSide == "left" && result.DeletedBy != "user-left" {
|
|
t.Errorf("expected left tombstone to win, got right")
|
|
}
|
|
if tt.expectedSide == "right" && result.DeletedBy != "user-right" {
|
|
t.Errorf("expected right tombstone to win, got left")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMerge3Way_TombstoneVsLive tests tombstone vs live issue scenarios
|
|
func TestMerge3Way_TombstoneVsLive(t *testing.T) {
|
|
// Base issue (live)
|
|
baseIssue := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "Original title",
|
|
Status: "open",
|
|
Priority: 2,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
UpdatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
}
|
|
|
|
// Recent tombstone (not expired)
|
|
recentTombstone := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "Original title",
|
|
Status: StatusTombstone,
|
|
Priority: 2,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
UpdatedAt: "2024-01-02T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
DeletedAt: time.Now().Add(-24 * time.Hour).Format(time.RFC3339), // 1 day ago
|
|
DeletedBy: "user2",
|
|
DeleteReason: "Duplicate issue",
|
|
OriginalType: "task",
|
|
}
|
|
|
|
// Expired tombstone (older than TTL)
|
|
expiredTombstone := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "Original title",
|
|
Status: StatusTombstone,
|
|
Priority: 2,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
UpdatedAt: "2024-01-02T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
DeletedAt: time.Now().Add(-60 * 24 * time.Hour).Format(time.RFC3339), // 60 days ago
|
|
DeletedBy: "user2",
|
|
DeleteReason: "Duplicate issue",
|
|
OriginalType: "task",
|
|
}
|
|
|
|
// Modified live issue
|
|
modifiedLive := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "Updated title",
|
|
Status: "in_progress",
|
|
Priority: 1,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
UpdatedAt: "2024-01-03T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
}
|
|
|
|
t.Run("recent tombstone in left wins over live in right", func(t *testing.T) {
|
|
base := []Issue{baseIssue}
|
|
left := []Issue{recentTombstone}
|
|
right := []Issue{modifiedLive}
|
|
|
|
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))
|
|
}
|
|
if result[0].Status != StatusTombstone {
|
|
t.Errorf("expected tombstone to win, got status %q", result[0].Status)
|
|
}
|
|
})
|
|
|
|
t.Run("recent tombstone in right wins over live in left", func(t *testing.T) {
|
|
base := []Issue{baseIssue}
|
|
left := []Issue{modifiedLive}
|
|
right := []Issue{recentTombstone}
|
|
|
|
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))
|
|
}
|
|
if result[0].Status != StatusTombstone {
|
|
t.Errorf("expected tombstone to win, got status %q", result[0].Status)
|
|
}
|
|
})
|
|
|
|
t.Run("expired tombstone in left loses to live in right (resurrection)", func(t *testing.T) {
|
|
base := []Issue{baseIssue}
|
|
left := []Issue{expiredTombstone}
|
|
right := []Issue{modifiedLive}
|
|
|
|
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))
|
|
}
|
|
if result[0].Status != "in_progress" {
|
|
t.Errorf("expected live issue to win over expired tombstone, got status %q", result[0].Status)
|
|
}
|
|
if result[0].Title != "Updated title" {
|
|
t.Errorf("expected live issue's title, got %q", result[0].Title)
|
|
}
|
|
})
|
|
|
|
t.Run("expired tombstone in right loses to live in left (resurrection)", func(t *testing.T) {
|
|
base := []Issue{baseIssue}
|
|
left := []Issue{modifiedLive}
|
|
right := []Issue{expiredTombstone}
|
|
|
|
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))
|
|
}
|
|
if result[0].Status != "in_progress" {
|
|
t.Errorf("expected live issue to win over expired tombstone, got status %q", result[0].Status)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMerge3Way_TombstoneVsTombstone tests merging two tombstones
|
|
func TestMerge3Way_TombstoneVsTombstone(t *testing.T) {
|
|
baseIssue := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "Original title",
|
|
Status: "open",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
}
|
|
|
|
t.Run("later tombstone wins", func(t *testing.T) {
|
|
leftTombstone := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "Original title",
|
|
Status: StatusTombstone,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
DeletedAt: "2024-01-02T00:00:00Z",
|
|
DeletedBy: "user-left",
|
|
DeleteReason: "Left reason",
|
|
}
|
|
rightTombstone := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "Original title",
|
|
Status: StatusTombstone,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
DeletedAt: "2024-01-03T00:00:00Z", // Later
|
|
DeletedBy: "user-right",
|
|
DeleteReason: "Right reason",
|
|
}
|
|
|
|
base := []Issue{baseIssue}
|
|
left := []Issue{leftTombstone}
|
|
right := []Issue{rightTombstone}
|
|
|
|
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))
|
|
}
|
|
if result[0].DeletedBy != "user-right" {
|
|
t.Errorf("expected right tombstone to win (later deleted_at), got DeletedBy %q", result[0].DeletedBy)
|
|
}
|
|
if result[0].DeleteReason != "Right reason" {
|
|
t.Errorf("expected right tombstone's reason, got %q", result[0].DeleteReason)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMerge3Way_TombstoneNoBase tests tombstone scenarios without a base
|
|
func TestMerge3Way_TombstoneNoBase(t *testing.T) {
|
|
t.Run("tombstone added only in left", func(t *testing.T) {
|
|
tombstone := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "New tombstone",
|
|
Status: StatusTombstone,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
DeletedAt: "2024-01-02T00:00:00Z",
|
|
DeletedBy: "user1",
|
|
}
|
|
|
|
result, conflicts := merge3Way([]Issue{}, []Issue{tombstone}, []Issue{})
|
|
if len(conflicts) != 0 {
|
|
t.Errorf("unexpected conflicts: %v", conflicts)
|
|
}
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 issue, got %d", len(result))
|
|
}
|
|
if result[0].Status != StatusTombstone {
|
|
t.Errorf("expected tombstone, got status %q", result[0].Status)
|
|
}
|
|
})
|
|
|
|
t.Run("tombstone added only in right", func(t *testing.T) {
|
|
tombstone := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "New tombstone",
|
|
Status: StatusTombstone,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
DeletedAt: "2024-01-02T00:00:00Z",
|
|
DeletedBy: "user1",
|
|
}
|
|
|
|
result, conflicts := merge3Way([]Issue{}, []Issue{}, []Issue{tombstone})
|
|
if len(conflicts) != 0 {
|
|
t.Errorf("unexpected conflicts: %v", conflicts)
|
|
}
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 issue, got %d", len(result))
|
|
}
|
|
if result[0].Status != StatusTombstone {
|
|
t.Errorf("expected tombstone, got status %q", result[0].Status)
|
|
}
|
|
})
|
|
|
|
t.Run("tombstone in left vs live in right (no base)", func(t *testing.T) {
|
|
recentTombstone := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "Issue",
|
|
Status: StatusTombstone,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
DeletedAt: time.Now().Add(-24 * time.Hour).Format(time.RFC3339),
|
|
DeletedBy: "user1",
|
|
}
|
|
live := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "Issue",
|
|
Status: "open",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
}
|
|
|
|
result, conflicts := merge3Way([]Issue{}, []Issue{recentTombstone}, []Issue{live})
|
|
if len(conflicts) != 0 {
|
|
t.Errorf("unexpected conflicts: %v", conflicts)
|
|
}
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 issue, got %d", len(result))
|
|
}
|
|
// Recent tombstone should win
|
|
if result[0].Status != StatusTombstone {
|
|
t.Errorf("expected tombstone to win, got status %q", result[0].Status)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMerge3WayWithTTL tests the TTL-configurable merge function
|
|
func TestMerge3WayWithTTL(t *testing.T) {
|
|
baseIssue := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "Original",
|
|
Status: "open",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
}
|
|
|
|
// Tombstone deleted 10 days ago
|
|
tombstone := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "Original",
|
|
Status: StatusTombstone,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
DeletedAt: time.Now().Add(-10 * 24 * time.Hour).Format(time.RFC3339),
|
|
DeletedBy: "user2",
|
|
}
|
|
|
|
liveIssue := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "Updated",
|
|
Status: "open",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
}
|
|
|
|
t.Run("with short TTL tombstone is expired", func(t *testing.T) {
|
|
// 7 day TTL + 1 hour grace = tombstone (10 days old) is expired
|
|
shortTTL := 7 * 24 * time.Hour
|
|
base := []Issue{baseIssue}
|
|
left := []Issue{tombstone}
|
|
right := []Issue{liveIssue}
|
|
|
|
result, _ := merge3WayWithTTL(base, left, right, shortTTL)
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 issue, got %d", len(result))
|
|
}
|
|
// With short TTL, tombstone is expired, live issue wins
|
|
if result[0].Status != "open" {
|
|
t.Errorf("expected live issue to win with short TTL, got status %q", result[0].Status)
|
|
}
|
|
})
|
|
|
|
t.Run("with long TTL tombstone is not expired", func(t *testing.T) {
|
|
// 30 day TTL = tombstone (10 days old) is NOT expired
|
|
longTTL := 30 * 24 * time.Hour
|
|
base := []Issue{baseIssue}
|
|
left := []Issue{tombstone}
|
|
right := []Issue{liveIssue}
|
|
|
|
result, _ := merge3WayWithTTL(base, left, right, longTTL)
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 issue, got %d", len(result))
|
|
}
|
|
// With long TTL, tombstone is NOT expired, tombstone wins
|
|
if result[0].Status != StatusTombstone {
|
|
t.Errorf("expected tombstone to win with long TTL, got status %q", result[0].Status)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMergeStatus_Tombstone tests status merging with tombstone
|
|
func TestMergeStatus_Tombstone(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
base string
|
|
left string
|
|
right string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "tombstone in left wins over open in right",
|
|
base: "open",
|
|
left: StatusTombstone,
|
|
right: "open",
|
|
expected: StatusTombstone,
|
|
},
|
|
{
|
|
name: "tombstone in right wins over open in left",
|
|
base: "open",
|
|
left: "open",
|
|
right: StatusTombstone,
|
|
expected: StatusTombstone,
|
|
},
|
|
{
|
|
name: "tombstone in left wins over closed in right",
|
|
base: "open",
|
|
left: StatusTombstone,
|
|
right: "closed",
|
|
expected: StatusTombstone,
|
|
},
|
|
{
|
|
name: "both tombstone",
|
|
base: "open",
|
|
left: StatusTombstone,
|
|
right: StatusTombstone,
|
|
expected: StatusTombstone,
|
|
},
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMerge3Way_TombstoneWithImplicitDeletion tests bd-ki14 fix:
|
|
// tombstones should be preserved even when the other side implicitly deleted
|
|
func TestMerge3Way_TombstoneWithImplicitDeletion(t *testing.T) {
|
|
baseIssue := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "Original",
|
|
Status: "open",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
}
|
|
|
|
tombstone := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "Original",
|
|
Status: StatusTombstone,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
DeletedAt: time.Now().Add(-24 * time.Hour).Format(time.RFC3339),
|
|
DeletedBy: "user2",
|
|
DeleteReason: "Duplicate",
|
|
}
|
|
|
|
t.Run("bd-ki14: tombstone in left preserved when right implicitly deleted", func(t *testing.T) {
|
|
base := []Issue{baseIssue}
|
|
left := []Issue{tombstone}
|
|
right := []Issue{} // Implicitly deleted in right
|
|
|
|
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 (tombstone preserved), got %d", len(result))
|
|
}
|
|
if result[0].Status != StatusTombstone {
|
|
t.Errorf("expected tombstone to be preserved, got status %q", result[0].Status)
|
|
}
|
|
if result[0].DeletedBy != "user2" {
|
|
t.Errorf("expected tombstone fields preserved, got DeletedBy %q", result[0].DeletedBy)
|
|
}
|
|
})
|
|
|
|
t.Run("bd-ki14: tombstone in right preserved when left implicitly deleted", func(t *testing.T) {
|
|
base := []Issue{baseIssue}
|
|
left := []Issue{} // Implicitly deleted in left
|
|
right := []Issue{tombstone}
|
|
|
|
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 (tombstone preserved), got %d", len(result))
|
|
}
|
|
if result[0].Status != StatusTombstone {
|
|
t.Errorf("expected tombstone to be preserved, got status %q", result[0].Status)
|
|
}
|
|
})
|
|
|
|
t.Run("bd-ki14: live issue in left still deleted when right implicitly deleted", func(t *testing.T) {
|
|
base := []Issue{baseIssue}
|
|
modifiedLive := Issue{
|
|
ID: "bd-abc123",
|
|
Title: "Modified",
|
|
Status: "in_progress",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
}
|
|
left := []Issue{modifiedLive}
|
|
right := []Issue{} // Implicitly deleted in right
|
|
|
|
result, conflicts := merge3Way(base, left, right)
|
|
if len(conflicts) != 0 {
|
|
t.Errorf("unexpected conflicts: %v", conflicts)
|
|
}
|
|
// Live issue should be deleted (implicit deletion wins for non-tombstones)
|
|
if len(result) != 0 {
|
|
t.Errorf("expected implicit deletion to win for live issue, got %d results", len(result))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMergeTombstones_EmptyDeletedAt tests bd-6x5 fix:
|
|
// handling empty DeletedAt timestamps in tombstone merging
|
|
func TestMergeTombstones_EmptyDeletedAt(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
leftDeletedAt string
|
|
rightDeletedAt string
|
|
expectedSide string // "left" or "right"
|
|
}{
|
|
{
|
|
name: "bd-6x5: both empty - left wins as tie-breaker",
|
|
leftDeletedAt: "",
|
|
rightDeletedAt: "",
|
|
expectedSide: "left",
|
|
},
|
|
{
|
|
name: "bd-6x5: left empty, right valid - right wins",
|
|
leftDeletedAt: "",
|
|
rightDeletedAt: "2024-01-01T00:00:00Z",
|
|
expectedSide: "right",
|
|
},
|
|
{
|
|
name: "bd-6x5: left valid, right empty - left wins",
|
|
leftDeletedAt: "2024-01-01T00:00:00Z",
|
|
rightDeletedAt: "",
|
|
expectedSide: "left",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
left := Issue{
|
|
ID: "bd-test",
|
|
Status: StatusTombstone,
|
|
DeletedAt: tt.leftDeletedAt,
|
|
DeletedBy: "user-left",
|
|
}
|
|
right := Issue{
|
|
ID: "bd-test",
|
|
Status: StatusTombstone,
|
|
DeletedAt: tt.rightDeletedAt,
|
|
DeletedBy: "user-right",
|
|
}
|
|
result := mergeTombstones(left, right)
|
|
if tt.expectedSide == "left" && result.DeletedBy != "user-left" {
|
|
t.Errorf("expected left tombstone to win, got DeletedBy %q", result.DeletedBy)
|
|
}
|
|
if tt.expectedSide == "right" && result.DeletedBy != "user-right" {
|
|
t.Errorf("expected right tombstone to win, got DeletedBy %q", result.DeletedBy)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMergeIssue_TombstoneFields tests bd-1sn fix:
|
|
// tombstone fields should be copied when status becomes tombstone via safety fallback
|
|
func TestMergeIssue_TombstoneFields(t *testing.T) {
|
|
t.Run("bd-1sn: tombstone fields copied from left when tombstone via mergeStatus", func(t *testing.T) {
|
|
base := Issue{
|
|
ID: "bd-test",
|
|
Status: "open",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
}
|
|
left := Issue{
|
|
ID: "bd-test",
|
|
Status: StatusTombstone,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
DeletedAt: "2024-01-02T00:00:00Z",
|
|
DeletedBy: "user2",
|
|
DeleteReason: "Duplicate",
|
|
OriginalType: "task",
|
|
}
|
|
right := Issue{
|
|
ID: "bd-test",
|
|
Status: "open",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
}
|
|
|
|
result, _ := mergeIssue(base, left, right)
|
|
if result.Status != StatusTombstone {
|
|
t.Errorf("expected tombstone status, got %q", result.Status)
|
|
}
|
|
if result.DeletedAt != "2024-01-02T00:00:00Z" {
|
|
t.Errorf("expected DeletedAt to be copied, got %q", result.DeletedAt)
|
|
}
|
|
if result.DeletedBy != "user2" {
|
|
t.Errorf("expected DeletedBy to be copied, got %q", result.DeletedBy)
|
|
}
|
|
if result.DeleteReason != "Duplicate" {
|
|
t.Errorf("expected DeleteReason to be copied, got %q", result.DeleteReason)
|
|
}
|
|
if result.OriginalType != "task" {
|
|
t.Errorf("expected OriginalType to be copied, got %q", result.OriginalType)
|
|
}
|
|
})
|
|
|
|
t.Run("bd-1sn: tombstone fields copied from right when it has later deleted_at", func(t *testing.T) {
|
|
base := Issue{
|
|
ID: "bd-test",
|
|
Status: "open",
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
}
|
|
left := Issue{
|
|
ID: "bd-test",
|
|
Status: StatusTombstone,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
DeletedAt: "2024-01-02T00:00:00Z",
|
|
DeletedBy: "user-left",
|
|
DeleteReason: "Left reason",
|
|
}
|
|
right := Issue{
|
|
ID: "bd-test",
|
|
Status: StatusTombstone,
|
|
CreatedAt: "2024-01-01T00:00:00Z",
|
|
CreatedBy: "user1",
|
|
DeletedAt: "2024-01-03T00:00:00Z", // Later
|
|
DeletedBy: "user-right",
|
|
DeleteReason: "Right reason",
|
|
}
|
|
|
|
result, _ := mergeIssue(base, left, right)
|
|
if result.Status != StatusTombstone {
|
|
t.Errorf("expected tombstone status, got %q", result.Status)
|
|
}
|
|
// Right has later deleted_at, so right's fields should be used
|
|
if result.DeletedBy != "user-right" {
|
|
t.Errorf("expected DeletedBy from right (later), got %q", result.DeletedBy)
|
|
}
|
|
if result.DeleteReason != "Right reason" {
|
|
t.Errorf("expected DeleteReason from right, got %q", result.DeleteReason)
|
|
}
|
|
})
|
|
}
|