- 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>
767 lines
22 KiB
Go
767 lines
22 KiB
Go
// Copyright (c) 2024 @neongreen (https://github.com/neongreen)
|
|
// Originally from: https://github.com/neongreen/mono/tree/main/beads-merge
|
|
//
|
|
// MIT License
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in all
|
|
// copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
//
|
|
// ---
|
|
// Vendored into beads with permission from @neongreen.
|
|
// See: https://github.com/neongreen/mono/issues/240
|
|
|
|
package merge
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// Issue represents a beads issue with all possible fields
|
|
type Issue struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
Priority int `json:"priority,omitempty"`
|
|
IssueType string `json:"issue_type,omitempty"`
|
|
CreatedAt string `json:"created_at,omitempty"`
|
|
UpdatedAt string `json:"updated_at,omitempty"`
|
|
ClosedAt string `json:"closed_at,omitempty"`
|
|
CreatedBy string `json:"created_by,omitempty"`
|
|
Dependencies []Dependency `json:"dependencies,omitempty"`
|
|
RawLine string `json:"-"` // Store original line for conflict output
|
|
// Tombstone fields (bd-0ih): inline soft-delete support for merge
|
|
DeletedAt string `json:"deleted_at,omitempty"` // When the issue was deleted
|
|
DeletedBy string `json:"deleted_by,omitempty"` // Who deleted the issue
|
|
DeleteReason string `json:"delete_reason,omitempty"` // Why the issue was deleted
|
|
OriginalType string `json:"original_type,omitempty"` // Issue type before deletion
|
|
}
|
|
|
|
// Dependency represents an issue dependency
|
|
type Dependency struct {
|
|
IssueID string `json:"issue_id"`
|
|
DependsOnID string `json:"depends_on_id"`
|
|
Type string `json:"type"`
|
|
CreatedAt string `json:"created_at"`
|
|
CreatedBy string `json:"created_by"`
|
|
}
|
|
|
|
// IssueKey uniquely identifies an issue for matching
|
|
type IssueKey struct {
|
|
ID string
|
|
CreatedAt string
|
|
CreatedBy string
|
|
}
|
|
|
|
// Merge3Way performs a 3-way merge of JSONL issue files
|
|
func Merge3Way(outputPath, basePath, leftPath, rightPath string, debug bool) error {
|
|
if debug {
|
|
fmt.Fprintf(os.Stderr, "=== DEBUG MODE ===\n")
|
|
fmt.Fprintf(os.Stderr, "Output path: %s\n", outputPath)
|
|
fmt.Fprintf(os.Stderr, "Base path: %s\n", basePath)
|
|
fmt.Fprintf(os.Stderr, "Left path: %s\n", leftPath)
|
|
fmt.Fprintf(os.Stderr, "Right path: %s\n", rightPath)
|
|
fmt.Fprintf(os.Stderr, "\n")
|
|
}
|
|
|
|
// Read all three files
|
|
baseIssues, err := readIssues(basePath)
|
|
if err != nil {
|
|
return fmt.Errorf("error reading base file: %w", err)
|
|
}
|
|
if debug {
|
|
fmt.Fprintf(os.Stderr, "Base issues read: %d\n", len(baseIssues))
|
|
}
|
|
|
|
leftIssues, err := readIssues(leftPath)
|
|
if err != nil {
|
|
return fmt.Errorf("error reading left file: %w", err)
|
|
}
|
|
if debug {
|
|
fmt.Fprintf(os.Stderr, "Left issues read: %d\n", len(leftIssues))
|
|
}
|
|
|
|
rightIssues, err := readIssues(rightPath)
|
|
if err != nil {
|
|
return fmt.Errorf("error reading right file: %w", err)
|
|
}
|
|
if debug {
|
|
fmt.Fprintf(os.Stderr, "Right issues read: %d\n", len(rightIssues))
|
|
fmt.Fprintf(os.Stderr, "\n")
|
|
}
|
|
|
|
// Perform 3-way merge
|
|
result, conflicts := merge3Way(baseIssues, leftIssues, rightIssues)
|
|
|
|
if debug {
|
|
fmt.Fprintf(os.Stderr, "Merge complete:\n")
|
|
fmt.Fprintf(os.Stderr, " Merged issues: %d\n", len(result))
|
|
fmt.Fprintf(os.Stderr, " Conflicts: %d\n", len(conflicts))
|
|
fmt.Fprintf(os.Stderr, "\n")
|
|
}
|
|
|
|
// Open output file for writing
|
|
outFile, err := os.Create(outputPath) // #nosec G304 -- outputPath provided by CLI flag but sanitized earlier
|
|
if err != nil {
|
|
return fmt.Errorf("error creating output file: %w", err)
|
|
}
|
|
defer outFile.Close()
|
|
|
|
// Write merged result to output file
|
|
for _, issue := range result {
|
|
line, err := json.Marshal(issue)
|
|
if err != nil {
|
|
return fmt.Errorf("error marshaling issue %s: %w", issue.ID, err)
|
|
}
|
|
if _, err := fmt.Fprintln(outFile, string(line)); err != nil {
|
|
return fmt.Errorf("error writing merged issue: %w", err)
|
|
}
|
|
}
|
|
|
|
// Write conflicts to output file
|
|
for _, conflict := range conflicts {
|
|
if _, err := fmt.Fprintln(outFile, conflict); err != nil {
|
|
return fmt.Errorf("error writing conflict: %w", err)
|
|
}
|
|
}
|
|
|
|
if debug {
|
|
fmt.Fprintf(os.Stderr, "Output written to: %s\n", outputPath)
|
|
fmt.Fprintf(os.Stderr, "\n")
|
|
|
|
// Show first few lines of output for debugging
|
|
if err := outFile.Sync(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to sync output file: %v\n", err)
|
|
}
|
|
// #nosec G304 -- debug output reads file created earlier in same function
|
|
if content, err := os.ReadFile(outputPath); err == nil {
|
|
lines := 0
|
|
fmt.Fprintf(os.Stderr, "Output file preview (first 10 lines):\n")
|
|
for _, line := range splitLines(string(content)) {
|
|
if lines >= 10 {
|
|
fmt.Fprintf(os.Stderr, "... (%d more lines)\n", len(splitLines(string(content)))-10)
|
|
break
|
|
}
|
|
fmt.Fprintf(os.Stderr, " %s\n", line)
|
|
lines++
|
|
}
|
|
}
|
|
fmt.Fprintf(os.Stderr, "\n")
|
|
}
|
|
|
|
// Return error if there were conflicts (caller can check this)
|
|
if len(conflicts) > 0 {
|
|
if debug {
|
|
fmt.Fprintf(os.Stderr, "Merge completed with %d conflicts\n", len(conflicts))
|
|
}
|
|
return fmt.Errorf("merge completed with %d conflicts", len(conflicts))
|
|
}
|
|
|
|
if debug {
|
|
fmt.Fprintf(os.Stderr, "Merge completed successfully with no conflicts\n")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func splitLines(s string) []string {
|
|
var lines []string
|
|
start := 0
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\n' {
|
|
lines = append(lines, s[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
if start < len(s) {
|
|
lines = append(lines, s[start:])
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func readIssues(path string) ([]Issue, error) {
|
|
file, err := os.Open(path) // #nosec G304 -- path supplied by CLI flag and validated upstream
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
var issues []Issue
|
|
scanner := bufio.NewScanner(file)
|
|
lineNum := 0
|
|
for scanner.Scan() {
|
|
lineNum++
|
|
line := scanner.Text()
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var issue Issue
|
|
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
|
return nil, fmt.Errorf("failed to parse line %d: %w", lineNum, err)
|
|
}
|
|
issue.RawLine = line
|
|
issues = append(issues, issue)
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("error reading file: %w", err)
|
|
}
|
|
|
|
return issues, nil
|
|
}
|
|
|
|
func makeKey(issue Issue) IssueKey {
|
|
return IssueKey{
|
|
ID: issue.ID,
|
|
CreatedAt: issue.CreatedAt,
|
|
CreatedBy: issue.CreatedBy,
|
|
}
|
|
}
|
|
|
|
// bd-ig5: Use constants from types package to avoid duplication
|
|
const StatusTombstone = string(types.StatusTombstone)
|
|
|
|
// Alias TTL constants from types package for local use
|
|
var (
|
|
DefaultTombstoneTTL = types.DefaultTombstoneTTL
|
|
ClockSkewGrace = types.ClockSkewGrace
|
|
)
|
|
|
|
// IsTombstone returns true if the issue has been soft-deleted
|
|
func IsTombstone(issue Issue) bool {
|
|
return issue.Status == StatusTombstone
|
|
}
|
|
|
|
// IsExpiredTombstone returns true if the tombstone has exceeded its TTL.
|
|
// Non-tombstone issues always return false.
|
|
// ttl is the configured TTL duration; if zero, DefaultTombstoneTTL is used.
|
|
func IsExpiredTombstone(issue Issue, ttl time.Duration) bool {
|
|
// Non-tombstones never expire
|
|
if !IsTombstone(issue) {
|
|
return false
|
|
}
|
|
|
|
// Tombstones without DeletedAt are not expired (safety: shouldn't happen in valid data)
|
|
if issue.DeletedAt == "" {
|
|
return false
|
|
}
|
|
|
|
// Use default TTL if not specified
|
|
if ttl == 0 {
|
|
ttl = DefaultTombstoneTTL
|
|
}
|
|
|
|
// Parse the deleted_at timestamp
|
|
deletedAt, err := time.Parse(time.RFC3339Nano, issue.DeletedAt)
|
|
if err != nil {
|
|
deletedAt, err = time.Parse(time.RFC3339, issue.DeletedAt)
|
|
if err != nil {
|
|
// Invalid timestamp means not expired (safety)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Add clock skew grace period to the TTL
|
|
effectiveTTL := ttl + ClockSkewGrace
|
|
|
|
// Check if the tombstone has exceeded its TTL
|
|
expirationTime := deletedAt.Add(effectiveTTL)
|
|
return time.Now().After(expirationTime)
|
|
}
|
|
|
|
func merge3Way(base, left, right []Issue) ([]Issue, []string) {
|
|
return merge3WayWithTTL(base, left, right, DefaultTombstoneTTL)
|
|
}
|
|
|
|
// merge3WayWithTTL performs a 3-way merge with configurable tombstone TTL.
|
|
// This is the core merge function that handles tombstone semantics.
|
|
func merge3WayWithTTL(base, left, right []Issue, ttl time.Duration) ([]Issue, []string) {
|
|
// Build maps for quick lookup
|
|
baseMap := make(map[IssueKey]Issue)
|
|
for _, issue := range base {
|
|
baseMap[makeKey(issue)] = issue
|
|
}
|
|
|
|
leftMap := make(map[IssueKey]Issue)
|
|
for _, issue := range left {
|
|
leftMap[makeKey(issue)] = issue
|
|
}
|
|
|
|
rightMap := make(map[IssueKey]Issue)
|
|
for _, issue := range right {
|
|
rightMap[makeKey(issue)] = issue
|
|
}
|
|
|
|
// Track which issues we've processed
|
|
processed := make(map[IssueKey]bool)
|
|
var result []Issue
|
|
var conflicts []string
|
|
|
|
// Process all unique keys
|
|
allKeys := make(map[IssueKey]bool)
|
|
for k := range baseMap {
|
|
allKeys[k] = true
|
|
}
|
|
for k := range leftMap {
|
|
allKeys[k] = true
|
|
}
|
|
for k := range rightMap {
|
|
allKeys[k] = true
|
|
}
|
|
|
|
for key := range allKeys {
|
|
if processed[key] {
|
|
continue
|
|
}
|
|
processed[key] = true
|
|
|
|
baseIssue, inBase := baseMap[key]
|
|
leftIssue, inLeft := leftMap[key]
|
|
rightIssue, inRight := rightMap[key]
|
|
|
|
// Determine tombstone status
|
|
leftTombstone := inLeft && IsTombstone(leftIssue)
|
|
rightTombstone := inRight && IsTombstone(rightIssue)
|
|
|
|
// Handle different scenarios
|
|
if inBase && inLeft && inRight {
|
|
// All three present - handle tombstone cases first
|
|
|
|
// CASE: Both are tombstones - merge tombstones (later deleted_at wins)
|
|
if leftTombstone && rightTombstone {
|
|
merged := mergeTombstones(leftIssue, rightIssue)
|
|
result = append(result, merged)
|
|
continue
|
|
}
|
|
|
|
// CASE: Left is tombstone, right is live
|
|
if leftTombstone && !rightTombstone {
|
|
if IsExpiredTombstone(leftIssue, ttl) {
|
|
// Tombstone expired - resurrection allowed, keep live issue
|
|
result = append(result, rightIssue)
|
|
} else {
|
|
// Tombstone wins
|
|
result = append(result, leftIssue)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// CASE: Right is tombstone, left is live
|
|
if rightTombstone && !leftTombstone {
|
|
if IsExpiredTombstone(rightIssue, ttl) {
|
|
// Tombstone expired - resurrection allowed, keep live issue
|
|
result = append(result, leftIssue)
|
|
} else {
|
|
// Tombstone wins
|
|
result = append(result, rightIssue)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// CASE: Both are live issues - standard merge
|
|
merged, conflict := mergeIssue(baseIssue, leftIssue, rightIssue)
|
|
if conflict != "" {
|
|
conflicts = append(conflicts, conflict)
|
|
} else {
|
|
result = append(result, merged)
|
|
}
|
|
} else if !inBase && inLeft && inRight {
|
|
// Added in both - handle tombstone cases
|
|
|
|
// CASE: Both are tombstones - merge tombstones
|
|
if leftTombstone && rightTombstone {
|
|
merged := mergeTombstones(leftIssue, rightIssue)
|
|
result = append(result, merged)
|
|
continue
|
|
}
|
|
|
|
// CASE: Left is tombstone, right is live
|
|
if leftTombstone && !rightTombstone {
|
|
if IsExpiredTombstone(leftIssue, ttl) {
|
|
result = append(result, rightIssue)
|
|
} else {
|
|
result = append(result, leftIssue)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// CASE: Right is tombstone, left is live
|
|
if rightTombstone && !leftTombstone {
|
|
if IsExpiredTombstone(rightIssue, ttl) {
|
|
result = append(result, leftIssue)
|
|
} else {
|
|
result = append(result, rightIssue)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// CASE: Both are live - merge using deterministic rules with empty base
|
|
emptyBase := Issue{
|
|
ID: leftIssue.ID,
|
|
CreatedAt: leftIssue.CreatedAt,
|
|
CreatedBy: leftIssue.CreatedBy,
|
|
}
|
|
merged, _ := mergeIssue(emptyBase, leftIssue, rightIssue)
|
|
result = append(result, merged)
|
|
} else if inBase && inLeft && !inRight {
|
|
// Deleted in right (implicitly), maybe modified in left
|
|
// bd-ki14: Check if left is a tombstone - tombstones must be preserved
|
|
if leftTombstone {
|
|
result = append(result, leftIssue)
|
|
continue
|
|
}
|
|
// 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 (implicitly), maybe modified in right
|
|
// bd-ki14: Check if right is a tombstone - tombstones must be preserved
|
|
if rightTombstone {
|
|
result = append(result, rightIssue)
|
|
continue
|
|
}
|
|
// 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 (could be a tombstone)
|
|
result = append(result, leftIssue)
|
|
} else if !inBase && !inLeft && inRight {
|
|
// Added only in right (could be a tombstone)
|
|
result = append(result, rightIssue)
|
|
}
|
|
}
|
|
|
|
return result, conflicts
|
|
}
|
|
|
|
// mergeTombstones merges two tombstones for the same issue.
|
|
// The tombstone with the later deleted_at timestamp wins.
|
|
//
|
|
// bd-6x5: Edge cases for empty DeletedAt:
|
|
// - If both empty: left wins (arbitrary but deterministic)
|
|
// - If left empty, right not: right wins (has timestamp)
|
|
// - If right empty, left not: left wins (has timestamp)
|
|
//
|
|
// Empty DeletedAt shouldn't happen in valid data (validation catches it),
|
|
// but we handle it defensively here.
|
|
func mergeTombstones(left, right Issue) Issue {
|
|
// bd-6x5: Handle empty DeletedAt explicitly for clarity
|
|
if left.DeletedAt == "" && right.DeletedAt == "" {
|
|
// Both invalid - left wins as tie-breaker
|
|
return left
|
|
}
|
|
if left.DeletedAt == "" {
|
|
// Left invalid, right valid - right wins
|
|
return right
|
|
}
|
|
if right.DeletedAt == "" {
|
|
// Right invalid, left valid - left wins
|
|
return left
|
|
}
|
|
// Both valid - use later deleted_at as the authoritative tombstone
|
|
if isTimeAfter(left.DeletedAt, right.DeletedAt) {
|
|
return left
|
|
}
|
|
return right
|
|
}
|
|
|
|
func mergeIssue(base, left, right Issue) (Issue, string) {
|
|
result := Issue{
|
|
ID: base.ID,
|
|
CreatedAt: base.CreatedAt,
|
|
CreatedBy: base.CreatedBy,
|
|
}
|
|
|
|
// Merge title - on conflict, side with latest updated_at wins
|
|
result.Title = mergeFieldByUpdatedAt(base.Title, left.Title, right.Title, left.UpdatedAt, right.UpdatedAt)
|
|
|
|
// Merge description - on conflict, side with latest updated_at wins
|
|
result.Description = mergeFieldByUpdatedAt(base.Description, left.Description, right.Description, left.UpdatedAt, right.UpdatedAt)
|
|
|
|
// Merge notes - on conflict, concatenate both sides
|
|
result.Notes = mergeNotes(base.Notes, left.Notes, right.Notes)
|
|
|
|
// Merge status - SPECIAL RULE: closed always wins over open
|
|
result.Status = mergeStatus(base.Status, left.Status, right.Status)
|
|
|
|
// Merge priority - on conflict, higher priority wins (lower number = more urgent)
|
|
result.Priority = mergePriority(base.Priority, left.Priority, right.Priority)
|
|
|
|
// Merge issue_type - on conflict, local (left) wins
|
|
result.IssueType = mergeField(base.IssueType, left.IssueType, right.IssueType)
|
|
|
|
// Merge updated_at - take the max
|
|
result.UpdatedAt = maxTime(left.UpdatedAt, right.UpdatedAt)
|
|
|
|
// 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)
|
|
|
|
// bd-1sn: If status became tombstone via mergeStatus safety fallback,
|
|
// copy tombstone fields from whichever side has them
|
|
if result.Status == StatusTombstone {
|
|
// Prefer the side with more recent deleted_at, or left if tied
|
|
if isTimeAfter(left.DeletedAt, right.DeletedAt) {
|
|
result.DeletedAt = left.DeletedAt
|
|
result.DeletedBy = left.DeletedBy
|
|
result.DeleteReason = left.DeleteReason
|
|
result.OriginalType = left.OriginalType
|
|
} else if right.DeletedAt != "" {
|
|
result.DeletedAt = right.DeletedAt
|
|
result.DeletedBy = right.DeletedBy
|
|
result.DeleteReason = right.DeleteReason
|
|
result.OriginalType = right.OriginalType
|
|
} else if left.DeletedAt != "" {
|
|
result.DeletedAt = left.DeletedAt
|
|
result.DeletedBy = left.DeletedBy
|
|
result.DeleteReason = left.DeleteReason
|
|
result.OriginalType = left.OriginalType
|
|
}
|
|
// Note: if neither has DeletedAt, tombstone fields remain empty
|
|
// This represents invalid data that validation should catch
|
|
}
|
|
|
|
// All field conflicts are now auto-resolved deterministically
|
|
return result, ""
|
|
}
|
|
|
|
func mergeStatus(base, left, right string) string {
|
|
// RULE 0: tombstone is handled at the merge3Way level, not here.
|
|
// If a tombstone status reaches here, it means both sides have the same
|
|
// issue with possibly different statuses - tombstone should not be one of them
|
|
// (that case is handled by the tombstone merge logic).
|
|
// However, if somehow one side has tombstone status, preserve it as a safety measure.
|
|
if left == StatusTombstone || right == StatusTombstone {
|
|
// This shouldn't happen in normal flow - tombstones are handled earlier
|
|
// But if it does, tombstone wins (deletion is explicit)
|
|
return StatusTombstone
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if base == right && base != left {
|
|
return left
|
|
}
|
|
// Both changed to same value or no change - left wins
|
|
return left
|
|
}
|
|
|
|
// mergeFieldByUpdatedAt resolves conflicts by picking the value from the side
|
|
// with the latest updated_at timestamp
|
|
func mergeFieldByUpdatedAt(base, left, right, leftUpdatedAt, rightUpdatedAt string) string {
|
|
// Standard 3-way merge for non-conflict cases
|
|
if base == left && base != right {
|
|
return right
|
|
}
|
|
if base == right && base != left {
|
|
return left
|
|
}
|
|
if left == right {
|
|
return left
|
|
}
|
|
// True conflict: both sides changed to different values
|
|
// Pick the value from the side with the latest updated_at
|
|
if isTimeAfter(leftUpdatedAt, rightUpdatedAt) {
|
|
return left
|
|
}
|
|
return right
|
|
}
|
|
|
|
// mergeNotes handles notes merging - on conflict, concatenate both sides
|
|
func mergeNotes(base, left, right string) string {
|
|
// Standard 3-way merge for non-conflict cases
|
|
if base == left && base != right {
|
|
return right
|
|
}
|
|
if base == right && base != left {
|
|
return left
|
|
}
|
|
if left == right {
|
|
return left
|
|
}
|
|
// True conflict: both sides changed to different values - concatenate
|
|
if left == "" {
|
|
return right
|
|
}
|
|
if right == "" {
|
|
return left
|
|
}
|
|
return left + "\n\n---\n\n" + right
|
|
}
|
|
|
|
// mergePriority handles priority merging - on conflict, higher priority wins (lower number)
|
|
// Special case: 0 is treated as "unset/no priority" due to Go's zero value.
|
|
// Any explicitly set priority (!=0) wins over 0. (bd-d0t fix, bd-1kf fix)
|
|
func mergePriority(base, left, right int) int {
|
|
// Standard 3-way merge for non-conflict cases
|
|
if base == left && base != right {
|
|
return right
|
|
}
|
|
if base == right && base != left {
|
|
return left
|
|
}
|
|
if left == right {
|
|
return left
|
|
}
|
|
// True conflict: both sides changed to different values
|
|
|
|
// bd-d0t fix: Treat 0 as "unset" - explicitly set priority wins over unset
|
|
// bd-1kf fix: Use != 0 instead of > 0 to handle negative priorities
|
|
if left == 0 && right != 0 {
|
|
return right // right has explicit priority, left is unset
|
|
}
|
|
if right == 0 && left != 0 {
|
|
return left // left has explicit priority, right is unset
|
|
}
|
|
|
|
// Both have explicit priorities (or both are 0) - higher priority wins (lower number = more urgent)
|
|
if left < right {
|
|
return left
|
|
}
|
|
return right
|
|
}
|
|
|
|
// isTimeAfter returns true if t1 is after t2
|
|
func isTimeAfter(t1, t2 string) bool {
|
|
if t1 == "" {
|
|
return false
|
|
}
|
|
if t2 == "" {
|
|
return true
|
|
}
|
|
|
|
time1, err1 := time.Parse(time.RFC3339Nano, t1)
|
|
if err1 != nil {
|
|
time1, err1 = time.Parse(time.RFC3339, t1)
|
|
}
|
|
|
|
time2, err2 := time.Parse(time.RFC3339Nano, t2)
|
|
if err2 != nil {
|
|
time2, err2 = time.Parse(time.RFC3339, t2)
|
|
}
|
|
|
|
// Handle parse errors consistently with maxTime:
|
|
// - Valid timestamp beats invalid
|
|
// - If both invalid, prefer left (t1) for consistency
|
|
if err1 != nil && err2 != nil {
|
|
return true // both invalid, prefer left
|
|
}
|
|
if err1 != nil {
|
|
return false // t1 invalid, t2 valid - t2 wins
|
|
}
|
|
if err2 != nil {
|
|
return true // t1 valid, t2 invalid - t1 wins
|
|
}
|
|
|
|
// Both valid - compare. On exact tie, left wins for consistency with IssueType rule (bd-8nz)
|
|
// Using !time2.After(time1) returns true when t1 > t2 OR t1 == t2
|
|
return !time2.After(time1)
|
|
}
|
|
|
|
func maxTime(t1, t2 string) string {
|
|
if t1 == "" && t2 == "" {
|
|
return ""
|
|
}
|
|
if t1 == "" {
|
|
return t2
|
|
}
|
|
if t2 == "" {
|
|
return t1
|
|
}
|
|
|
|
// Try RFC3339Nano first (supports fractional seconds), fall back to RFC3339
|
|
time1, err1 := time.Parse(time.RFC3339Nano, t1)
|
|
if err1 != nil {
|
|
time1, err1 = time.Parse(time.RFC3339, t1)
|
|
}
|
|
|
|
time2, err2 := time.Parse(time.RFC3339Nano, t2)
|
|
if err2 != nil {
|
|
time2, err2 = time.Parse(time.RFC3339, t2)
|
|
}
|
|
|
|
// If both fail to parse, return t2 as fallback
|
|
if err1 != nil && err2 != nil {
|
|
return t2
|
|
}
|
|
// If only t1 failed to parse, return t2
|
|
if err1 != nil {
|
|
return t2
|
|
}
|
|
// If only t2 failed to parse, return t1
|
|
if err2 != nil {
|
|
return t1
|
|
}
|
|
|
|
if time1.After(time2) {
|
|
return t1
|
|
}
|
|
return t2
|
|
}
|
|
|
|
func mergeDependencies(left, right []Dependency) []Dependency {
|
|
seen := make(map[string]bool)
|
|
var result []Dependency
|
|
|
|
for _, dep := range left {
|
|
key := fmt.Sprintf("%s:%s:%s", dep.IssueID, dep.DependsOnID, dep.Type)
|
|
if !seen[key] {
|
|
seen[key] = true
|
|
result = append(result, dep)
|
|
}
|
|
}
|
|
|
|
for _, dep := range right {
|
|
key := fmt.Sprintf("%s:%s:%s", dep.IssueID, dep.DependsOnID, dep.Type)
|
|
if !seen[key] {
|
|
seen[key] = true
|
|
result = append(result, dep)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|