Files
beads/cmd/bd/linear.go
Zachary Piazza ee44498659 feat(linear): add --type and --exclude-type flags for sync filtering (#1205)
* feat(linear): add --type and --exclude-type flags for sync filtering

Add type filtering support to `bd linear sync --push` to allow users to
control which issue types are synced to Linear.

New flags:
- --type: Only sync issues matching these types (e.g., --type=task,feature)
- --exclude-type: Exclude issues of these types (e.g., --exclude-type=wisp)

Use cases:
- Sync only work items (tasks, features, bugs) while excluding internal
  telemetry (wisps, messages)
- Push only specific issue types to Linear

Fixes #1204

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(linear): update test to match new doPushToLinear signature

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 14:05:48 -08:00

657 lines
20 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"
bd config set linear.project_id "PROJECT_ID" # Optional: sync only this project
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
Type Filtering (--push only):
--type task,feature Only sync issues of these types
--exclude-type wisp Exclude issues of these types
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 --push --type=task,feature # Push only tasks and features
bd linear sync --push --exclude-type=wisp # Push all except wisps
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")
linearSyncCmd.Flags().StringSlice("type", nil, "Only sync issues of these types (can be repeated)")
linearSyncCmd.Flags().StringSlice("exclude-type", nil, "Exclude issues of these types (can be repeated)")
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")
typeFilters, _ := cmd.Flags().GetStringSlice("type")
excludeTypes, _ := cmd.Flags().GetStringSlice("exclude-type")
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, typeFilters, excludeTypes)
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)
}
// Filter to specific project if configured
if projectID, _ := store.GetConfig(ctx, "linear.project_id"); projectID != "" {
client = client.WithProjectID(projectID)
}
}
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
}