Add QualityScore *float32 field to internal/merge/merge.go to match internal/types/types.go. Also add last-touched to .beads/.gitignore. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
926 lines
27 KiB
Go
926 lines
27 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"
|
|
"cmp"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"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"` // No omitempty: 0 is valid (P0/critical)
|
|
IssueType string `json:"issue_type,omitempty"`
|
|
CreatedAt string `json:"created_at,omitempty"`
|
|
UpdatedAt string `json:"updated_at,omitempty"`
|
|
ClosedAt string `json:"closed_at,omitempty"`
|
|
CloseReason string `json:"close_reason,omitempty"` // Reason provided when closing (GH#891)
|
|
ClosedBySession string `json:"closed_by_session,omitempty"` // Session that closed this issue (GH#891)
|
|
CreatedBy string `json:"created_by,omitempty"`
|
|
Dependencies []Dependency `json:"dependencies,omitempty"`
|
|
RawLine string `json:"-"` // Store original line for conflict output
|
|
// Tombstone fields: 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
|
|
// HOP quality field
|
|
QualityScore *float32 `json:"quality_score,omitempty"` // Aggregate quality (0.0-1.0)
|
|
}
|
|
|
|
// 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, debug)
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
// Use constants from types package to avoid duplication
|
|
const (
|
|
StatusTombstone = string(types.StatusTombstone)
|
|
StatusClosed = string(types.StatusClosed)
|
|
)
|
|
|
|
// 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, debug bool) ([]Issue, []string) {
|
|
return Merge3WayWithTTL(base, left, right, DefaultTombstoneTTL, debug)
|
|
}
|
|
|
|
// Merge3WayWithTTL performs a 3-way merge with configurable tombstone TTL.
|
|
// This is the core merge function that handles tombstone semantics.
|
|
// Use this when you need to configure TTL for testing, debugging, or
|
|
// per-repository configuration. For default TTL behavior, use merge3Way.
|
|
// When debug is true, logs resurrection events to stderr.
|
|
func Merge3WayWithTTL(base, left, right []Issue, ttl time.Duration, debug bool) ([]Issue, []string) {
|
|
// Build maps for quick lookup by IssueKey
|
|
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
|
|
}
|
|
|
|
// Also build ID-based maps for fallback matching
|
|
// This handles cases where the same issue has slightly different CreatedAt/CreatedBy
|
|
// (e.g., due to timestamp precision differences between systems)
|
|
leftByID := make(map[string]Issue)
|
|
for _, issue := range left {
|
|
leftByID[issue.ID] = issue
|
|
}
|
|
|
|
rightByID := make(map[string]Issue)
|
|
for _, issue := range right {
|
|
rightByID[issue.ID] = issue
|
|
}
|
|
|
|
// Track which issues we've processed (by both key and ID)
|
|
processed := make(map[IssueKey]bool)
|
|
processedIDs := make(map[string]bool) // track processed IDs to avoid duplicates
|
|
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]
|
|
|
|
// ID-based fallback matching for tombstone preservation
|
|
// If key doesn't match but same ID exists in the other side, use that
|
|
if !inLeft && inRight {
|
|
if fallback, found := leftByID[rightIssue.ID]; found {
|
|
leftIssue = fallback
|
|
inLeft = true
|
|
// Mark the fallback's key as processed to avoid duplicate
|
|
processed[makeKey(fallback)] = true
|
|
}
|
|
}
|
|
if !inRight && inLeft {
|
|
if fallback, found := rightByID[leftIssue.ID]; found {
|
|
rightIssue = fallback
|
|
inRight = true
|
|
// Mark the fallback's key as processed to avoid duplicate
|
|
processed[makeKey(fallback)] = true
|
|
}
|
|
}
|
|
|
|
// Check if we've already processed this ID (via a different key)
|
|
currentID := key.ID
|
|
if currentID == "" {
|
|
if inLeft {
|
|
currentID = leftIssue.ID
|
|
} else if inRight {
|
|
currentID = rightIssue.ID
|
|
} else if inBase {
|
|
currentID = baseIssue.ID
|
|
}
|
|
}
|
|
if currentID != "" && processedIDs[currentID] {
|
|
continue
|
|
}
|
|
if currentID != "" {
|
|
processedIDs[currentID] = true
|
|
}
|
|
|
|
// 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
|
|
if debug {
|
|
fmt.Fprintf(os.Stderr, "Issue %s resurrected (tombstone expired)\n", rightIssue.ID)
|
|
}
|
|
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
|
|
if debug {
|
|
fmt.Fprintf(os.Stderr, "Issue %s resurrected (tombstone expired)\n", leftIssue.ID)
|
|
}
|
|
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) {
|
|
if debug {
|
|
fmt.Fprintf(os.Stderr, "Issue %s resurrected (tombstone expired)\n", rightIssue.ID)
|
|
}
|
|
result = append(result, rightIssue)
|
|
} else {
|
|
result = append(result, leftIssue)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// CASE: Right is tombstone, left is live
|
|
if rightTombstone && !leftTombstone {
|
|
if IsExpiredTombstone(rightIssue, ttl) {
|
|
if debug {
|
|
fmt.Fprintf(os.Stderr, "Issue %s resurrected (tombstone expired)\n", leftIssue.ID)
|
|
}
|
|
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
|
|
// 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
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// Sort by ID for deterministic output (matches bd export behavior)
|
|
slices.SortFunc(result, func(a, b Issue) int {
|
|
return cmp.Compare(a.ID, b.ID)
|
|
})
|
|
|
|
return result, conflicts
|
|
}
|
|
|
|
// mergeTombstones merges two tombstones for the same issue.
|
|
// The tombstone with the later deleted_at timestamp wins.
|
|
//
|
|
// 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 {
|
|
// 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 == StatusClosed {
|
|
result.ClosedAt = maxTime(left.ClosedAt, right.ClosedAt)
|
|
// Merge close_reason and closed_by_session - use value from side with later closed_at (GH#891)
|
|
// This ensures we keep the most recent close action's metadata
|
|
if isTimeAfter(left.ClosedAt, right.ClosedAt) {
|
|
result.CloseReason = left.CloseReason
|
|
result.ClosedBySession = left.ClosedBySession
|
|
} else if right.ClosedAt != "" {
|
|
result.CloseReason = right.CloseReason
|
|
result.ClosedBySession = right.ClosedBySession
|
|
} else {
|
|
// Both empty or only left has value - prefer left
|
|
result.CloseReason = left.CloseReason
|
|
result.ClosedBySession = left.ClosedBySession
|
|
}
|
|
} else {
|
|
result.ClosedAt = ""
|
|
result.CloseReason = ""
|
|
result.ClosedBySession = ""
|
|
}
|
|
|
|
// Merge dependencies - proper 3-way merge where removals win
|
|
result.Dependencies = mergeDependencies(base.Dependencies, left.Dependencies, right.Dependencies)
|
|
|
|
// 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 == StatusClosed || right == StatusClosed {
|
|
return StatusClosed
|
|
}
|
|
|
|
// 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.
|
|
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
|
|
|
|
// Treat 0 as "unset" - explicitly set priority wins over unset
|
|
// 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
|
|
// 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
|
|
}
|
|
|
|
// mergeDependencies performs a proper 3-way merge of dependencies
|
|
// Key principle: REMOVALS ARE AUTHORITATIVE
|
|
// - If dep was in base and removed by left OR right → exclude (removal wins)
|
|
// - If dep wasn't in base and added by left OR right → include
|
|
// - If dep was in base and both still have it → include
|
|
func mergeDependencies(base, left, right []Dependency) []Dependency {
|
|
// Build sets for O(1) lookup
|
|
depKey := func(dep Dependency) string {
|
|
return fmt.Sprintf("%s:%s:%s", dep.IssueID, dep.DependsOnID, dep.Type)
|
|
}
|
|
|
|
baseSet := make(map[string]bool)
|
|
for _, dep := range base {
|
|
baseSet[depKey(dep)] = true
|
|
}
|
|
|
|
leftSet := make(map[string]bool)
|
|
leftDeps := make(map[string]Dependency)
|
|
for _, dep := range left {
|
|
key := depKey(dep)
|
|
leftSet[key] = true
|
|
leftDeps[key] = dep
|
|
}
|
|
|
|
rightSet := make(map[string]bool)
|
|
rightDeps := make(map[string]Dependency)
|
|
for _, dep := range right {
|
|
key := depKey(dep)
|
|
rightSet[key] = true
|
|
rightDeps[key] = dep
|
|
}
|
|
|
|
// Collect all unique keys
|
|
allKeys := make(map[string]bool)
|
|
for k := range baseSet {
|
|
allKeys[k] = true
|
|
}
|
|
for k := range leftSet {
|
|
allKeys[k] = true
|
|
}
|
|
for k := range rightSet {
|
|
allKeys[k] = true
|
|
}
|
|
|
|
var result []Dependency
|
|
seen := make(map[string]bool)
|
|
|
|
for key := range allKeys {
|
|
inBase := baseSet[key]
|
|
inLeft := leftSet[key]
|
|
inRight := rightSet[key]
|
|
|
|
// 3-way merge logic:
|
|
if inBase {
|
|
// Was in base - check if either side removed it
|
|
if !inLeft {
|
|
// Left removed it → don't include (left wins)
|
|
continue
|
|
}
|
|
if !inRight {
|
|
// Right removed it → don't include (right wins)
|
|
continue
|
|
}
|
|
// Both still have it → include
|
|
} else {
|
|
// Wasn't in base - must have been added by left or right
|
|
if !inLeft && !inRight {
|
|
// Neither has it (shouldn't happen but handle gracefully)
|
|
continue
|
|
}
|
|
// At least one side added it → include
|
|
}
|
|
|
|
if !seen[key] {
|
|
seen[key] = true
|
|
// Prefer left's version of the dep (for any metadata differences)
|
|
if dep, ok := leftDeps[key]; ok {
|
|
result = append(result, dep)
|
|
} else if dep, ok := rightDeps[key]; ok {
|
|
result = append(result, dep)
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|