Files
beads/cmd/bd/linear.go
Ryan Snodgrass acfdcebc0f fix: misc improvements and dependency updates
- Update nix vendorHash after fatih/color removal
- Bump version to 0.30.7
- Add GroupID to remaining commands for proper cobra grouping
- Apply semantic color rendering to list and stale commands
- Update pre-commit hook template
2025-12-20 17:09:50 -08:00

1191 lines
35 KiB
Go

package main
import (
"context"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/debug"
"github.com/steveyegge/beads/internal/linear"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
// linearCmd is the root command for Linear integration.
var linearCmd = &cobra.Command{
Use: "linear",
GroupID: "advanced",
Short: "Linear integration commands",
Long: `Synchronize issues between beads and Linear.
Configuration:
bd config set linear.api_key "YOUR_API_KEY"
bd config set linear.team_id "TEAM_ID"
Environment variables (alternative to config):
LINEAR_API_KEY - Linear API key
LINEAR_TEAM_ID - Linear team ID (UUID)
Data Mapping (optional, sensible defaults provided):
Priority mapping (Linear 0-4 to Beads 0-4):
bd config set linear.priority_map.0 4 # No priority -> Backlog
bd config set linear.priority_map.1 0 # Urgent -> Critical
bd config set linear.priority_map.2 1 # High -> High
bd config set linear.priority_map.3 2 # Medium -> Medium
bd config set linear.priority_map.4 3 # Low -> Low
State mapping (Linear state type to Beads status):
bd config set linear.state_map.backlog open
bd config set linear.state_map.unstarted open
bd config set linear.state_map.started in_progress
bd config set linear.state_map.completed closed
bd config set linear.state_map.canceled closed
bd config set linear.state_map.my_custom_state in_progress # Custom state names
Label to issue type mapping:
bd config set linear.label_type_map.bug bug
bd config set linear.label_type_map.feature feature
bd config set linear.label_type_map.epic epic
Relation type mapping (Linear relations to Beads dependencies):
bd config set linear.relation_map.blocks blocks
bd config set linear.relation_map.blockedBy blocks
bd config set linear.relation_map.duplicate duplicates
bd config set linear.relation_map.related related
ID generation (optional, hash IDs to match bd/Jira hash mode):
bd config set linear.id_mode "hash" # hash (default)
bd config set linear.hash_length "6" # hash length 3-8 (default: 6)
Examples:
bd linear sync --pull # Import issues from Linear
bd linear sync --push # Export issues to Linear
bd linear sync # Bidirectional sync (pull then push)
bd linear sync --dry-run # Preview sync without changes
bd linear status # Show sync status`,
}
// linearSyncCmd handles synchronization with Linear.
var linearSyncCmd = &cobra.Command{
Use: "sync",
Short: "Synchronize issues with Linear",
Long: `Synchronize issues between beads and Linear.
Modes:
--pull Import issues from Linear into beads
--push Export issues from beads to Linear
(no flags) Bidirectional sync: pull then push, with conflict resolution
Conflict Resolution:
By default, newer timestamp wins. Override with:
--prefer-local Always prefer local beads version
--prefer-linear Always prefer Linear version
Examples:
bd linear sync --pull # Import from Linear
bd linear sync --push --create-only # Push new issues only
bd linear sync --dry-run # Preview without changes
bd linear sync --prefer-local # Bidirectional, local wins`,
Run: runLinearSync,
}
// linearStatusCmd shows the current sync status.
var linearStatusCmd = &cobra.Command{
Use: "status",
Short: "Show Linear sync status",
Long: `Show the current Linear sync status, including:
- Last sync timestamp
- Configuration status
- Number of issues with Linear links
- Issues pending push (no external_ref)`,
Run: runLinearStatus,
}
// linearTeamsCmd lists available teams.
var linearTeamsCmd = &cobra.Command{
Use: "teams",
Short: "List available Linear teams",
Long: `List all teams accessible with your Linear API key.
Use this to find the team ID (UUID) needed for configuration.
Example:
bd linear teams
bd config set linear.team_id "12345678-1234-1234-1234-123456789abc"`,
Run: runLinearTeams,
}
func init() {
linearSyncCmd.Flags().Bool("pull", false, "Pull issues from Linear")
linearSyncCmd.Flags().Bool("push", false, "Push issues to Linear")
linearSyncCmd.Flags().Bool("dry-run", false, "Preview sync without making changes")
linearSyncCmd.Flags().Bool("prefer-local", false, "Prefer local version on conflicts")
linearSyncCmd.Flags().Bool("prefer-linear", false, "Prefer Linear version on conflicts")
linearSyncCmd.Flags().Bool("create-only", false, "Only create new issues, don't update existing")
linearSyncCmd.Flags().Bool("update-refs", true, "Update external_ref after creating Linear issues")
linearSyncCmd.Flags().String("state", "all", "Issue state to sync: open, closed, all")
linearCmd.AddCommand(linearSyncCmd)
linearCmd.AddCommand(linearStatusCmd)
linearCmd.AddCommand(linearTeamsCmd)
rootCmd.AddCommand(linearCmd)
}
func runLinearSync(cmd *cobra.Command, args []string) {
pull, _ := cmd.Flags().GetBool("pull")
push, _ := cmd.Flags().GetBool("push")
dryRun, _ := cmd.Flags().GetBool("dry-run")
preferLocal, _ := cmd.Flags().GetBool("prefer-local")
preferLinear, _ := cmd.Flags().GetBool("prefer-linear")
createOnly, _ := cmd.Flags().GetBool("create-only")
updateRefs, _ := cmd.Flags().GetBool("update-refs")
state, _ := cmd.Flags().GetString("state")
if !dryRun {
CheckReadonly("linear sync")
}
if preferLocal && preferLinear {
fmt.Fprintf(os.Stderr, "Error: cannot use both --prefer-local and --prefer-linear\n")
os.Exit(1)
}
if err := ensureStoreActive(); err != nil {
fmt.Fprintf(os.Stderr, "Error: database not available: %v\n", err)
os.Exit(1)
}
if err := validateLinearConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if !pull && !push {
pull = true
push = true
}
ctx := rootCtx
result := &linear.SyncResult{Success: true}
var forceUpdateIDs map[string]bool
var skipUpdateIDs map[string]bool
var prePullConflicts []linear.Conflict
var prePullSkipLinearIDs map[string]bool
if pull {
if preferLocal || preferLinear {
conflicts, err := detectLinearConflicts(ctx)
if err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("conflict detection failed: %v", err))
} else if len(conflicts) > 0 {
prePullConflicts = conflicts
if preferLocal {
prePullSkipLinearIDs = make(map[string]bool, len(conflicts))
forceUpdateIDs = make(map[string]bool, len(conflicts))
for _, conflict := range conflicts {
prePullSkipLinearIDs[conflict.LinearIdentifier] = true
forceUpdateIDs[conflict.IssueID] = true
}
} else if preferLinear {
skipUpdateIDs = make(map[string]bool, len(conflicts))
for _, conflict := range conflicts {
skipUpdateIDs[conflict.IssueID] = true
}
}
}
}
if dryRun {
fmt.Println("→ [DRY RUN] Would pull issues from Linear")
} else {
fmt.Println("→ Pulling issues from Linear...")
}
pullStats, err := doPullFromLinear(ctx, dryRun, state, prePullSkipLinearIDs)
if err != nil {
result.Success = false
result.Error = err.Error()
if jsonOutput {
outputJSON(result)
} else {
fmt.Fprintf(os.Stderr, "Error pulling from Linear: %v\n", err)
}
os.Exit(1)
}
result.Stats.Pulled = pullStats.Created + pullStats.Updated
result.Stats.Created += pullStats.Created
result.Stats.Updated += pullStats.Updated
result.Stats.Skipped += pullStats.Skipped
if !dryRun {
fmt.Printf("✓ Pulled %d issues (%d created, %d updated)\n",
result.Stats.Pulled, pullStats.Created, pullStats.Updated)
}
}
if pull && push {
conflicts := prePullConflicts
var err error
if conflicts == nil {
conflicts, err = detectLinearConflicts(ctx)
}
if err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("conflict detection failed: %v", err))
} else if len(conflicts) > 0 {
result.Stats.Conflicts = len(conflicts)
if dryRun {
if preferLocal {
fmt.Printf("→ [DRY RUN] Would resolve %d conflicts (preferring local)\n", len(conflicts))
forceUpdateIDs = make(map[string]bool, len(conflicts))
for _, conflict := range conflicts {
forceUpdateIDs[conflict.IssueID] = true
}
} else if preferLinear {
fmt.Printf("→ [DRY RUN] Would resolve %d conflicts (preferring Linear)\n", len(conflicts))
skipUpdateIDs = make(map[string]bool, len(conflicts))
for _, conflict := range conflicts {
skipUpdateIDs[conflict.IssueID] = true
}
} else {
fmt.Printf("→ [DRY RUN] Would resolve %d conflicts (newer wins)\n", len(conflicts))
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(localWins) > 0 {
forceUpdateIDs = make(map[string]bool, len(localWins))
for _, conflict := range localWins {
forceUpdateIDs[conflict.IssueID] = true
}
}
if len(linearWins) > 0 {
skipUpdateIDs = make(map[string]bool, len(linearWins))
for _, conflict := range linearWins {
skipUpdateIDs[conflict.IssueID] = true
}
}
}
} else if preferLocal {
fmt.Printf("→ Resolving %d conflicts (preferring local)\n", len(conflicts))
if forceUpdateIDs == nil {
forceUpdateIDs = make(map[string]bool, len(conflicts))
for _, conflict := range conflicts {
forceUpdateIDs[conflict.IssueID] = true
}
}
} else if preferLinear {
fmt.Printf("→ Resolving %d conflicts (preferring Linear)\n", len(conflicts))
if skipUpdateIDs == nil {
skipUpdateIDs = make(map[string]bool, len(conflicts))
for _, conflict := range conflicts {
skipUpdateIDs[conflict.IssueID] = true
}
}
if prePullConflicts == nil {
if err := reimportLinearConflicts(ctx, conflicts); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("conflict resolution failed: %v", err))
}
}
} else {
fmt.Printf("→ Resolving %d conflicts (newer wins)\n", len(conflicts))
if err := resolveLinearConflictsByTimestamp(ctx, conflicts); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("conflict resolution failed: %v", err))
}
}
}
}
if push {
if dryRun {
fmt.Println("→ [DRY RUN] Would push issues to Linear")
} else {
fmt.Println("→ Pushing issues to Linear...")
}
pushStats, err := doPushToLinear(ctx, dryRun, createOnly, updateRefs, forceUpdateIDs, skipUpdateIDs)
if err != nil {
result.Success = false
result.Error = err.Error()
if jsonOutput {
outputJSON(result)
} else {
fmt.Fprintf(os.Stderr, "Error pushing to Linear: %v\n", err)
}
os.Exit(1)
}
result.Stats.Pushed = pushStats.Created + pushStats.Updated
result.Stats.Created += pushStats.Created
result.Stats.Updated += pushStats.Updated
result.Stats.Skipped += pushStats.Skipped
result.Stats.Errors += pushStats.Errors
if !dryRun {
fmt.Printf("✓ Pushed %d issues (%d created, %d updated)\n",
result.Stats.Pushed, pushStats.Created, pushStats.Updated)
}
}
if !dryRun && result.Success {
result.LastSync = time.Now().Format(time.RFC3339)
if err := store.SetConfig(ctx, "linear.last_sync", result.LastSync); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("failed to update last_sync: %v", err))
}
}
if jsonOutput {
outputJSON(result)
} else if dryRun {
fmt.Println("\n✓ Dry run complete (no changes made)")
} else {
fmt.Println("\n✓ Linear sync complete")
if len(result.Warnings) > 0 {
fmt.Println("\nWarnings:")
for _, w := range result.Warnings {
fmt.Printf(" - %s\n", w)
}
}
}
}
func runLinearStatus(cmd *cobra.Command, args []string) {
ctx := rootCtx
if err := ensureStoreActive(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
apiKey, _ := getLinearConfig(ctx, "linear.api_key")
teamID, _ := getLinearConfig(ctx, "linear.team_id")
lastSync, _ := store.GetConfig(ctx, "linear.last_sync")
configured := apiKey != "" && teamID != ""
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
withLinearRef := 0
pendingPush := 0
for _, issue := range allIssues {
if issue.ExternalRef != nil && linear.IsLinearExternalRef(*issue.ExternalRef) {
withLinearRef++
} else if issue.ExternalRef == nil {
pendingPush++
}
}
if jsonOutput {
hasAPIKey := apiKey != ""
outputJSON(map[string]interface{}{
"configured": configured,
"has_api_key": hasAPIKey,
"team_id": teamID,
"last_sync": lastSync,
"total_issues": len(allIssues),
"with_linear_ref": withLinearRef,
"pending_push": pendingPush,
})
return
}
fmt.Println("Linear Sync Status")
fmt.Println("==================")
fmt.Println()
if !configured {
fmt.Println("Status: Not configured")
fmt.Println()
fmt.Println("To configure Linear integration:")
fmt.Println(" bd config set linear.api_key \"YOUR_API_KEY\"")
fmt.Println(" bd config set linear.team_id \"TEAM_ID\"")
fmt.Println()
fmt.Println("Or use environment variables:")
fmt.Println(" export LINEAR_API_KEY=\"YOUR_API_KEY\"")
fmt.Println(" export LINEAR_TEAM_ID=\"TEAM_ID\"")
return
}
fmt.Printf("Team ID: %s\n", teamID)
fmt.Printf("API Key: %s\n", maskAPIKey(apiKey))
if lastSync != "" {
fmt.Printf("Last Sync: %s\n", lastSync)
} else {
fmt.Println("Last Sync: Never")
}
fmt.Println()
fmt.Printf("Total Issues: %d\n", len(allIssues))
fmt.Printf("With Linear: %d\n", withLinearRef)
fmt.Printf("Local Only: %d\n", pendingPush)
if pendingPush > 0 {
fmt.Println()
fmt.Printf("Run 'bd linear sync --push' to push %d local issue(s) to Linear\n", pendingPush)
}
}
func runLinearTeams(cmd *cobra.Command, args []string) {
ctx := rootCtx
apiKey, apiKeySource := getLinearConfig(ctx, "linear.api_key")
if apiKey == "" {
fmt.Fprintf(os.Stderr, "Error: Linear API key not configured\n")
fmt.Fprintf(os.Stderr, "Run: bd config set linear.api_key \"YOUR_API_KEY\"\n")
fmt.Fprintf(os.Stderr, "Or: export LINEAR_API_KEY=YOUR_API_KEY\n")
os.Exit(1)
}
debug.Logf("Using API key from %s", apiKeySource)
client := linear.NewClient(apiKey, "")
teams, err := client.FetchTeams(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error fetching teams: %v\n", err)
os.Exit(1)
}
if len(teams) == 0 {
fmt.Println("No teams found (check your API key permissions)")
return
}
if jsonOutput {
outputJSON(teams)
return
}
fmt.Println("Available Linear Teams")
fmt.Println("======================")
fmt.Println()
fmt.Printf("%-40s %-6s %s\n", "ID (use this for linear.team_id)", "Key", "Name")
fmt.Printf("%-40s %-6s %s\n", "----------------------------------------", "------", "----")
for _, team := range teams {
fmt.Printf("%-40s %-6s %s\n", team.ID, team.Key, team.Name)
}
fmt.Println()
fmt.Println("To configure:")
fmt.Println(" bd config set linear.team_id \"<ID>\"")
}
// uuidRegex matches valid UUID format (with or without hyphens).
var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}$`)
func isValidUUID(s string) bool {
return uuidRegex.MatchString(s)
}
// validateLinearConfig checks that required Linear configuration is present.
func validateLinearConfig() error {
if err := ensureStoreActive(); err != nil {
return fmt.Errorf("database not available: %w", err)
}
ctx := rootCtx
apiKey, _ := getLinearConfig(ctx, "linear.api_key")
if apiKey == "" {
return fmt.Errorf("Linear API key not configured\nRun: bd config set linear.api_key \"YOUR_API_KEY\"\nOr: export LINEAR_API_KEY=YOUR_API_KEY")
}
teamID, _ := getLinearConfig(ctx, "linear.team_id")
if teamID == "" {
return fmt.Errorf("linear.team_id not configured\nRun: bd config set linear.team_id \"TEAM_ID\"\nOr: export LINEAR_TEAM_ID=TEAM_ID")
}
if !isValidUUID(teamID) {
return fmt.Errorf("linear.team_id appears invalid (expected UUID format like '12345678-1234-1234-1234-123456789abc')\nCurrent value: %s", teamID)
}
return nil
}
// maskAPIKey returns a masked version of an API key for display.
// Shows first 4 and last 4 characters, with dots in between.
func maskAPIKey(key string) string {
if len(key) <= 8 {
return "****"
}
return key[:4] + "..." + key[len(key)-4:]
}
// getLinearConfig reads a Linear configuration value, handling both daemon mode
// (where store is nil) and direct mode. Returns the value and its source.
// Priority: project config > environment variable.
func getLinearConfig(ctx context.Context, key string) (value string, source string) {
// Try to read from store (works in direct mode)
if store != nil {
value, _ = store.GetConfig(ctx, key)
if value != "" {
return value, "project config (bd config)"
}
} else if dbPath != "" {
tempStore, err := sqlite.NewWithTimeout(ctx, dbPath, 5*time.Second)
if err == nil {
defer func() { _ = tempStore.Close() }()
value, _ = tempStore.GetConfig(ctx, key)
if value != "" {
return value, "project config (bd config)"
}
}
}
// Fall back to environment variable
envKey := linearConfigToEnvVar(key)
if envKey != "" {
value = os.Getenv(envKey)
if value != "" {
return value, fmt.Sprintf("environment variable (%s)", envKey)
}
}
return "", ""
}
// linearConfigToEnvVar maps Linear config keys to their environment variable names.
func linearConfigToEnvVar(key string) string {
switch key {
case "linear.api_key":
return "LINEAR_API_KEY"
case "linear.team_id":
return "LINEAR_TEAM_ID"
default:
return ""
}
}
// getLinearClient creates a configured Linear client from beads config.
func getLinearClient(ctx context.Context) (*linear.Client, error) {
apiKey, _ := getLinearConfig(ctx, "linear.api_key")
if apiKey == "" {
return nil, fmt.Errorf("Linear API key not configured")
}
teamID, _ := getLinearConfig(ctx, "linear.team_id")
if teamID == "" {
return nil, fmt.Errorf("Linear team ID not configured")
}
client := linear.NewClient(apiKey, teamID)
if store != nil {
if endpoint, _ := store.GetConfig(ctx, "linear.api_endpoint"); endpoint != "" {
client = client.WithEndpoint(endpoint)
}
}
return client, nil
}
// storeConfigLoader adapts the store to the linear.ConfigLoader interface.
type storeConfigLoader struct {
ctx context.Context
}
func (l *storeConfigLoader) GetAllConfig() (map[string]string, error) {
return store.GetAllConfig(l.ctx)
}
// loadLinearMappingConfig loads mapping configuration from beads config.
func loadLinearMappingConfig(ctx context.Context) *linear.MappingConfig {
if store == nil {
return linear.DefaultMappingConfig()
}
return linear.LoadMappingConfig(&storeConfigLoader{ctx: ctx})
}
// getLinearIDMode returns the configured ID mode for Linear imports.
// Supported values: "hash" (default) or "db".
func getLinearIDMode(ctx context.Context) string {
mode, _ := getLinearConfig(ctx, "linear.id_mode")
mode = strings.ToLower(strings.TrimSpace(mode))
if mode == "" {
return "hash"
}
return mode
}
// getLinearHashLength returns the configured hash length for Linear imports.
// Values are clamped to the supported range 3-8.
func getLinearHashLength(ctx context.Context) int {
raw, _ := getLinearConfig(ctx, "linear.hash_length")
if raw == "" {
return 6
}
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return 6
}
if value < 3 {
return 3
}
if value > 8 {
return 8
}
return value
}
// 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
}
// doPullFromLinear imports issues from Linear using the GraphQL API.
// Supports incremental sync by checking linear.last_sync config and only fetching
// issues updated since that timestamp.
func doPullFromLinear(ctx context.Context, dryRun bool, state string, skipLinearIDs map[string]bool) (*linear.PullStats, error) {
stats := &linear.PullStats{}
client, err := getLinearClient(ctx)
if err != nil {
return stats, fmt.Errorf("failed to create Linear client: %w", err)
}
var linearIssues []linear.Issue
lastSyncStr, _ := store.GetConfig(ctx, "linear.last_sync")
if lastSyncStr != "" {
lastSync, err := time.Parse(time.RFC3339, lastSyncStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: invalid linear.last_sync timestamp, doing full sync\n")
linearIssues, err = client.FetchIssues(ctx, state)
if err != nil {
return stats, fmt.Errorf("failed to fetch issues from Linear: %w", err)
}
} else {
stats.Incremental = true
stats.SyncedSince = lastSyncStr
linearIssues, err = client.FetchIssuesSince(ctx, state, lastSync)
if err != nil {
return stats, fmt.Errorf("failed to fetch issues from Linear (incremental): %w", err)
}
if !dryRun {
fmt.Printf(" Incremental sync since %s\n", lastSync.Format("2006-01-02 15:04:05"))
}
}
} else {
linearIssues, err = client.FetchIssues(ctx, state)
if err != nil {
return stats, fmt.Errorf("failed to fetch issues from Linear: %w", err)
}
if !dryRun {
fmt.Println(" Full sync (no previous sync timestamp)")
}
}
mappingConfig := loadLinearMappingConfig(ctx)
idMode := getLinearIDMode(ctx)
hashLength := getLinearHashLength(ctx)
var beadsIssues []*types.Issue
var allDeps []linear.DependencyInfo
linearIDToBeadsID := make(map[string]string)
for i := range linearIssues {
conversion := linear.IssueToBeads(&linearIssues[i], mappingConfig)
beadsIssues = append(beadsIssues, conversion.Issue.(*types.Issue))
allDeps = append(allDeps, conversion.Dependencies...)
}
if len(beadsIssues) == 0 {
fmt.Println(" No issues to import")
return stats, nil
}
if len(skipLinearIDs) > 0 {
var filteredIssues []*types.Issue
skipped := 0
for _, issue := range beadsIssues {
if issue.ExternalRef == nil {
filteredIssues = append(filteredIssues, issue)
continue
}
linearID := linear.ExtractLinearIdentifier(*issue.ExternalRef)
if linearID != "" && skipLinearIDs[linearID] {
skipped++
continue
}
filteredIssues = append(filteredIssues, issue)
}
if skipped > 0 {
stats.Skipped += skipped
}
beadsIssues = filteredIssues
if len(allDeps) > 0 {
var filteredDeps []linear.DependencyInfo
for _, dep := range allDeps {
if skipLinearIDs[dep.FromLinearID] || skipLinearIDs[dep.ToLinearID] {
continue
}
filteredDeps = append(filteredDeps, dep)
}
allDeps = filteredDeps
}
}
prefix, err := store.GetConfig(ctx, "issue_prefix")
if err != nil || prefix == "" {
prefix = "bd"
}
if idMode == "hash" {
existingIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{IncludeTombstones: true})
if err != nil {
return stats, fmt.Errorf("failed to fetch existing issues for ID collision avoidance: %w", err)
}
usedIDs := make(map[string]bool, len(existingIssues))
for _, issue := range existingIssues {
if issue.ID != "" {
usedIDs[issue.ID] = true
}
}
idOpts := linear.IDGenerationOptions{
BaseLength: hashLength,
MaxLength: 8,
UsedIDs: usedIDs,
}
if err := linear.GenerateIssueIDs(beadsIssues, prefix, "linear-import", idOpts); err != nil {
return stats, fmt.Errorf("failed to generate issue IDs: %w", err)
}
} else if idMode != "db" {
return stats, fmt.Errorf("unsupported linear.id_mode %q (expected \"hash\" or \"db\")", idMode)
}
opts := ImportOptions{
DryRun: dryRun,
SkipUpdate: false,
}
result, err := importIssuesCore(ctx, dbPath, store, beadsIssues, opts)
if err != nil {
return stats, fmt.Errorf("import failed: %w", err)
}
stats.Created = result.Created
stats.Updated = result.Updated
stats.Skipped = result.Skipped
if dryRun {
if stats.Incremental {
fmt.Printf(" Would import %d issues from Linear (incremental since %s)\n",
len(linearIssues), stats.SyncedSince)
} else {
fmt.Printf(" Would import %d issues from Linear (full sync)\n", len(linearIssues))
}
return stats, nil
}
allBeadsIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to fetch issues for dependency mapping: %v\n", err)
return stats, nil
}
for _, issue := range allBeadsIssues {
if issue.ExternalRef != nil && linear.IsLinearExternalRef(*issue.ExternalRef) {
linearID := linear.ExtractLinearIdentifier(*issue.ExternalRef)
if linearID != "" {
linearIDToBeadsID[linearID] = issue.ID
}
}
}
depsCreated := 0
for _, dep := range allDeps {
fromID, fromOK := linearIDToBeadsID[dep.FromLinearID]
toID, toOK := linearIDToBeadsID[dep.ToLinearID]
if !fromOK || !toOK {
continue
}
dependency := &types.Dependency{
IssueID: fromID,
DependsOnID: toID,
Type: types.DependencyType(dep.Type),
CreatedAt: time.Now(),
}
err := store.AddDependency(ctx, dependency, actor)
if err != nil {
if !strings.Contains(err.Error(), "already exists") &&
!strings.Contains(err.Error(), "duplicate") {
fmt.Fprintf(os.Stderr, "Warning: failed to create dependency %s -> %s (%s): %v\n",
fromID, toID, dep.Type, err)
}
} else {
depsCreated++
}
}
if depsCreated > 0 {
fmt.Printf(" Created %d dependencies from Linear relations\n", depsCreated)
}
return stats, nil
}
// doPushToLinear exports issues to Linear using the GraphQL API.
func doPushToLinear(ctx context.Context, dryRun bool, createOnly bool, updateRefs bool, forceUpdateIDs map[string]bool, skipUpdateIDs map[string]bool) (*linear.PushStats, error) {
stats := &linear.PushStats{}
client, err := getLinearClient(ctx)
if err != nil {
return stats, fmt.Errorf("failed to create Linear client: %w", err)
}
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
return stats, fmt.Errorf("failed to get local issues: %w", err)
}
var toCreate []*types.Issue
var toUpdate []*types.Issue
for _, issue := range allIssues {
if issue.IsTombstone() {
continue
}
if issue.ExternalRef != nil && linear.IsLinearExternalRef(*issue.ExternalRef) {
if !createOnly {
toUpdate = append(toUpdate, issue)
}
} else if issue.ExternalRef == nil {
toCreate = append(toCreate, issue)
}
}
var stateCache *linear.StateCache
if !dryRun && (len(toCreate) > 0 || (!createOnly && len(toUpdate) > 0)) {
stateCache, err = linear.BuildStateCache(ctx, client)
if err != nil {
return stats, fmt.Errorf("failed to fetch team states: %w", err)
}
}
mappingConfig := loadLinearMappingConfig(ctx)
for _, issue := range toCreate {
if dryRun {
stats.Created++
continue
}
linearPriority := linear.PriorityToLinear(issue.Priority, mappingConfig)
stateID := stateCache.FindStateForBeadsStatus(issue.Status)
description := linear.BuildLinearDescription(issue)
linearIssue, err := client.CreateIssue(ctx, issue.Title, description, linearPriority, stateID, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create issue '%s' in Linear: %v\n", issue.Title, err)
stats.Errors++
continue
}
stats.Created++
fmt.Printf(" Created: %s -> %s\n", issue.ID, linearIssue.Identifier)
if updateRefs && linearIssue.URL != "" {
externalRef := linearIssue.URL
if canonical, ok := linear.CanonicalizeLinearExternalRef(externalRef); ok {
externalRef = canonical
}
updates := map[string]interface{}{
"external_ref": externalRef,
}
if err := store.UpdateIssue(ctx, issue.ID, updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to update external_ref for %s: %v\n", issue.ID, err)
stats.Errors++
}
}
}
if len(toUpdate) > 0 && !createOnly {
for _, issue := range toUpdate {
if skipUpdateIDs != nil && skipUpdateIDs[issue.ID] {
stats.Skipped++
continue
}
linearIdentifier := linear.ExtractLinearIdentifier(*issue.ExternalRef)
if linearIdentifier == "" {
fmt.Fprintf(os.Stderr, "Warning: could not extract Linear identifier from %s: %s\n",
issue.ID, *issue.ExternalRef)
stats.Errors++
continue
}
linearIssue, err := client.FetchIssueByIdentifier(ctx, linearIdentifier)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to fetch Linear issue %s: %v\n",
linearIdentifier, err)
stats.Errors++
continue
}
if linearIssue == nil {
fmt.Fprintf(os.Stderr, "Warning: Linear issue %s not found (may have been deleted)\n",
linearIdentifier)
stats.Skipped++
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)
stats.Errors++
continue
}
forcedUpdate := forceUpdateIDs != nil && forceUpdateIDs[issue.ID]
if !forcedUpdate && !issue.UpdatedAt.After(linearUpdatedAt) {
stats.Skipped++
continue
}
if !forcedUpdate {
localComparable := linear.NormalizeIssueForLinearHash(issue)
linearComparable := linear.IssueToBeads(linearIssue, mappingConfig).Issue.(*types.Issue)
if localComparable.ComputeContentHash() == linearComparable.ComputeContentHash() {
stats.Skipped++
continue
}
}
if dryRun {
stats.Updated++
continue
}
description := linear.BuildLinearDescription(issue)
updatePayload := map[string]interface{}{
"title": issue.Title,
"description": description,
}
linearPriority := linear.PriorityToLinear(issue.Priority, mappingConfig)
if linearPriority > 0 {
updatePayload["priority"] = linearPriority
}
stateID := stateCache.FindStateForBeadsStatus(issue.Status)
if stateID != "" {
updatePayload["stateId"] = stateID
}
updatedLinearIssue, err := client.UpdateIssue(ctx, linearIssue.ID, updatePayload)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to update Linear issue %s: %v\n",
linearIdentifier, err)
stats.Errors++
continue
}
stats.Updated++
fmt.Printf(" Updated: %s -> %s\n", issue.ID, updatedLinearIssue.Identifier)
}
}
if dryRun {
fmt.Printf(" Would create %d issues in Linear\n", stats.Created)
if !createOnly {
fmt.Printf(" Would update %d issues in Linear\n", stats.Updated)
}
}
return stats, nil
}