Split 6 files exceeding 800 lines by extracting cohesive function groups: - show.go (1592→578): extracted show_thread.go, close.go, edit.go, update.go - doctor.go (1295→690): extracted doctor_fix.go, doctor_health.go, doctor_pollution.go - sync.go (1201→749): extracted sync_git.go - compact.go (1199→775): extracted compact_tombstone.go, compact_rpc.go - linear.go (1190→641): extracted linear_sync.go, linear_conflict.go - main.go (1148→800): extracted main_help.go, main_errors.go, main_daemon.go All files now under 800-line acceptance criteria. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
191 lines
5.1 KiB
Go
191 lines
5.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/linear"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// detectLinearConflicts finds issues that have been modified both locally and in Linear
|
|
// since the last sync. This is a more expensive operation as it fetches individual
|
|
// issue timestamps from Linear.
|
|
func detectLinearConflicts(ctx context.Context) ([]linear.Conflict, error) {
|
|
lastSyncStr, _ := store.GetConfig(ctx, "linear.last_sync")
|
|
if lastSyncStr == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
lastSync, err := time.Parse(time.RFC3339, lastSyncStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid last_sync timestamp: %w", err)
|
|
}
|
|
|
|
config := loadLinearMappingConfig(ctx)
|
|
|
|
client, err := getLinearClient(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create Linear client: %w", err)
|
|
}
|
|
|
|
// Get all local issues with Linear external refs
|
|
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var conflicts []linear.Conflict
|
|
|
|
for _, issue := range allIssues {
|
|
if issue.ExternalRef == nil || !linear.IsLinearExternalRef(*issue.ExternalRef) {
|
|
continue
|
|
}
|
|
|
|
if !issue.UpdatedAt.After(lastSync) {
|
|
continue
|
|
}
|
|
|
|
linearIdentifier := linear.ExtractLinearIdentifier(*issue.ExternalRef)
|
|
if linearIdentifier == "" {
|
|
continue
|
|
}
|
|
|
|
linearIssue, err := client.FetchIssueByIdentifier(ctx, linearIdentifier)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to fetch Linear issue %s for conflict check: %v\n",
|
|
linearIdentifier, err)
|
|
continue
|
|
}
|
|
if linearIssue == nil {
|
|
continue
|
|
}
|
|
|
|
linearUpdatedAt, err := time.Parse(time.RFC3339, linearIssue.UpdatedAt)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to parse Linear UpdatedAt for %s: %v\n",
|
|
linearIdentifier, err)
|
|
continue
|
|
}
|
|
|
|
if !linearUpdatedAt.After(lastSync) {
|
|
continue
|
|
}
|
|
|
|
localComparable := linear.NormalizeIssueForLinearHash(issue)
|
|
linearComparable := linear.IssueToBeads(linearIssue, config).Issue.(*types.Issue)
|
|
if localComparable.ComputeContentHash() == linearComparable.ComputeContentHash() {
|
|
continue
|
|
}
|
|
|
|
conflicts = append(conflicts, linear.Conflict{
|
|
IssueID: issue.ID,
|
|
LocalUpdated: issue.UpdatedAt,
|
|
LinearUpdated: linearUpdatedAt,
|
|
LinearExternalRef: *issue.ExternalRef,
|
|
LinearIdentifier: linearIdentifier,
|
|
LinearInternalID: linearIssue.ID,
|
|
})
|
|
}
|
|
|
|
return conflicts, nil
|
|
}
|
|
|
|
// reimportLinearConflicts re-imports conflicting issues from Linear (Linear wins).
|
|
// For each conflict, fetches the current state from Linear and updates the local copy.
|
|
func reimportLinearConflicts(ctx context.Context, conflicts []linear.Conflict) error {
|
|
if len(conflicts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
client, err := getLinearClient(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create Linear client: %w", err)
|
|
}
|
|
|
|
config := loadLinearMappingConfig(ctx)
|
|
resolved := 0
|
|
failed := 0
|
|
|
|
for _, conflict := range conflicts {
|
|
linearIssue, err := client.FetchIssueByIdentifier(ctx, conflict.LinearIdentifier)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, " Warning: failed to fetch %s for resolution: %v\n",
|
|
conflict.LinearIdentifier, err)
|
|
failed++
|
|
continue
|
|
}
|
|
if linearIssue == nil {
|
|
fmt.Fprintf(os.Stderr, " Warning: Linear issue %s not found, skipping\n",
|
|
conflict.LinearIdentifier)
|
|
failed++
|
|
continue
|
|
}
|
|
|
|
updates := linear.BuildLinearToLocalUpdates(linearIssue, config)
|
|
|
|
err = store.UpdateIssue(ctx, conflict.IssueID, updates, actor)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, " Warning: failed to update local issue %s: %v\n",
|
|
conflict.IssueID, err)
|
|
failed++
|
|
continue
|
|
}
|
|
|
|
fmt.Printf(" Resolved: %s <- %s (Linear wins)\n", conflict.IssueID, conflict.LinearIdentifier)
|
|
resolved++
|
|
}
|
|
|
|
if failed > 0 {
|
|
return fmt.Errorf("%d conflict(s) failed to resolve", failed)
|
|
}
|
|
|
|
fmt.Printf(" Resolved %d conflict(s) by keeping Linear version\n", resolved)
|
|
return nil
|
|
}
|
|
|
|
// resolveLinearConflictsByTimestamp resolves conflicts by keeping the newer version.
|
|
// For each conflict, compares local and Linear UpdatedAt timestamps.
|
|
// If Linear is newer, re-imports from Linear. If local is newer, push will overwrite.
|
|
func resolveLinearConflictsByTimestamp(ctx context.Context, conflicts []linear.Conflict) error {
|
|
if len(conflicts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var linearWins []linear.Conflict
|
|
var localWins []linear.Conflict
|
|
|
|
for _, conflict := range conflicts {
|
|
if conflict.LinearUpdated.After(conflict.LocalUpdated) {
|
|
linearWins = append(linearWins, conflict)
|
|
} else {
|
|
localWins = append(localWins, conflict)
|
|
}
|
|
}
|
|
|
|
if len(linearWins) > 0 {
|
|
fmt.Printf(" %d conflict(s): Linear is newer, will re-import\n", len(linearWins))
|
|
}
|
|
if len(localWins) > 0 {
|
|
fmt.Printf(" %d conflict(s): Local is newer, will push to Linear\n", len(localWins))
|
|
}
|
|
|
|
if len(linearWins) > 0 {
|
|
err := reimportLinearConflicts(ctx, linearWins)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to re-import Linear-wins conflicts: %w", err)
|
|
}
|
|
}
|
|
|
|
if len(localWins) > 0 {
|
|
for _, conflict := range localWins {
|
|
fmt.Printf(" Resolved: %s -> %s (local wins, will push)\n",
|
|
conflict.IssueID, conflict.LinearIdentifier)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|