feat: Linear Integration (#655)
* Add Linear integration CLI with sync and status commands - Add `bd linear sync` for bidirectional issue sync with Linear - Add `bd linear status` to show configuration and sync state - Stub pull/push functions pending GraphQL client (bd-b6b.2) * Implement Linear GraphQL client with full sync support - Add LinearClient with auth, fetch, create, update methods - Implement pull/push operations with Beads type mapping - Clean up redundant comments and remove unused code * Add configurable data mapping and dependency sync for Linear - Add LinearMappingConfig with configurable priority/state/label/relation maps - Import parent-child and issue relations as Beads dependencies - Support custom workflow states via linear.state_map.* config * Add incremental sync support for Linear integration - Add FetchIssuesSince() method using updatedAt filter in GraphQL - Check linear.last_sync config to enable incremental pulls - Track sync mode (incremental vs full) in LinearPullStats * feat(linear): implement push updates for existing Linear issues Add FetchIssueByIdentifier method to retrieve single issues by identifier (e.g., "TEAM-123") for timestamp comparison during push. Update doPushToLinear to: - Fetch Linear issue to get internal ID and UpdatedAt timestamp - Compare timestamps: only update if local is newer - Build update payload with title, description, priority, and state - Call UpdateIssue for issues where local has newer changes Closes bd-b6b.5 * Implement Linear conflict resolution strategies - Add true conflict detection by fetching Linear timestamps via API - Implement --prefer-linear resolution (re-import from Linear) - Implement timestamp-based resolution (newer wins as default) - Fix linter issues: handle resp.Body.Close() and remove unused error return * Add Linear integration tests and documentation - Add comprehensive unit tests for Linear mapping (priority, state, labels, relations) - Update docs/CONFIG.md with Linear configuration reference - Add examples/linear-workflow guide for bidirectional sync - Remove AI section header comments from tests * Fix Linear GraphQL filter construction and improve test coverage - Refactor filter handling to combine team ID into main filter object - Add test for duplicate issue relation mapping - Add HTTP round-trip helper for testing request payload validation * Refactor Linear queries to use shared constant and add UUID validation - Extract linearIssuesQuery to deduplicate FetchIssues/FetchIssuesSince - Add linearMaxPageSize constant and UUID validation with regex - Expand test coverage for new functionality * Refactor Linear integration into internal/linear package - Extract types, client, and mapping logic from cmd/bd/linear.go - Create internal/linear/ package for better code organization - Update tests to work with new package structure * Add linear teams command to list available teams - Add FetchTeams GraphQL query to Linear client - Refactor config reading to support daemon mode - Add tests for teams listing functionality * Refactor Linear config to use getLinearConfig helper - Consolidate config/env var lookup using getLinearConfig function - Add LINEAR_TEAM_ID environment variable support - Update error messages to include env var configuration options * Add hash ID generation and improve Linear conflict detection - Add configurable hash ID mode for Linear imports (matches bd/Jira) - Improve conflict detection with content hash comparison - Enhance conflict resolution with skip/force update tracking * Fix test for updated doPushToLinear signature - Add missing skipUpdateIDs parameter to test call
This commit is contained in:
+102
-84
@@ -7,6 +7,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/beads/internal/linear"
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
@@ -29,12 +30,12 @@ const (
|
||||
|
||||
// Options contains import configuration
|
||||
type Options struct {
|
||||
DryRun bool // Preview changes without applying them
|
||||
SkipUpdate bool // Skip updating existing issues (create-only mode)
|
||||
Strict bool // Fail on any error (dependencies, labels, etc.)
|
||||
RenameOnImport bool // Rename imported issues to match database prefix
|
||||
SkipPrefixValidation bool // Skip prefix validation (for auto-import)
|
||||
OrphanHandling OrphanHandling // How to handle missing parent issues (default: allow)
|
||||
DryRun bool // Preview changes without applying them
|
||||
SkipUpdate bool // Skip updating existing issues (create-only mode)
|
||||
Strict bool // Fail on any error (dependencies, labels, etc.)
|
||||
RenameOnImport bool // Rename imported issues to match database prefix
|
||||
SkipPrefixValidation bool // Skip prefix validation (for auto-import)
|
||||
OrphanHandling OrphanHandling // How to handle missing parent issues (default: allow)
|
||||
ClearDuplicateExternalRefs bool // Clear duplicate external_ref values instead of erroring
|
||||
ProtectLocalExportIDs map[string]bool // IDs from left snapshot to protect from deletion (bd-sync-deletion fix)
|
||||
}
|
||||
@@ -78,6 +79,18 @@ func ImportIssues(ctx context.Context, dbPath string, store storage.Storage, iss
|
||||
MismatchPrefixes: make(map[string]int),
|
||||
}
|
||||
|
||||
// Normalize Linear external_refs to canonical form to avoid slug-based duplicates.
|
||||
for _, issue := range issues {
|
||||
if issue.ExternalRef == nil || *issue.ExternalRef == "" {
|
||||
continue
|
||||
}
|
||||
if linear.IsLinearExternalRef(*issue.ExternalRef) {
|
||||
if canonical, ok := linear.CanonicalizeLinearExternalRef(*issue.ExternalRef); ok {
|
||||
issue.ExternalRef = &canonical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute content hashes for all incoming issues (bd-95)
|
||||
// Always recompute to avoid stale/incorrect JSONL hashes (bd-1231)
|
||||
for _, issue := range issues {
|
||||
@@ -92,7 +105,7 @@ func ImportIssues(ctx context.Context, dbPath string, store storage.Storage, iss
|
||||
if needCloseStore {
|
||||
defer func() { _ = sqliteStore.Close() }()
|
||||
}
|
||||
|
||||
|
||||
// Clear export_hashes before import to prevent staleness (bd-160)
|
||||
// Import operations may add/update issues, so export_hashes entries become invalid
|
||||
if !opts.DryRun {
|
||||
@@ -100,7 +113,7 @@ func ImportIssues(ctx context.Context, dbPath string, store storage.Storage, iss
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to clear export_hashes before import: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Read orphan handling from config if not explicitly set
|
||||
if opts.OrphanHandling == "" {
|
||||
opts.OrphanHandling = sqliteStore.GetOrphanHandling(ctx)
|
||||
@@ -358,7 +371,7 @@ func handleRename(ctx context.Context, s *sqlite.SQLiteStorage, existing *types.
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
|
||||
|
||||
/* OLD CODE REMOVED (bd-8e05)
|
||||
// Different content - this is a collision during rename
|
||||
// Allocate a new ID for the incoming issue instead of using the desired ID
|
||||
@@ -366,9 +379,9 @@ func handleRename(ctx context.Context, s *sqlite.SQLiteStorage, existing *types.
|
||||
if err != nil || prefix == "" {
|
||||
prefix = "bd"
|
||||
}
|
||||
|
||||
|
||||
oldID := existing.ID
|
||||
|
||||
|
||||
// Retry up to 3 times to handle concurrent ID allocation
|
||||
const maxRetries = 3
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
@@ -376,42 +389,42 @@ func handleRename(ctx context.Context, s *sqlite.SQLiteStorage, existing *types.
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate new ID for rename collision: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Update incoming issue to use the new ID
|
||||
incoming.ID = newID
|
||||
|
||||
|
||||
// Delete old ID (only on first attempt)
|
||||
if attempt == 0 {
|
||||
if err := s.DeleteIssue(ctx, oldID); err != nil {
|
||||
return "", fmt.Errorf("failed to delete old ID %s: %w", oldID, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create with new ID
|
||||
err = s.CreateIssue(ctx, incoming, "import-rename-collision")
|
||||
if err == nil {
|
||||
// Success!
|
||||
return oldID, nil
|
||||
}
|
||||
|
||||
|
||||
// Check if it's a UNIQUE constraint error
|
||||
if !sqlite.IsUniqueConstraintError(err) {
|
||||
// Not a UNIQUE constraint error, fail immediately
|
||||
return "", fmt.Errorf("failed to create renamed issue with collision resolution %s: %w", newID, err)
|
||||
}
|
||||
|
||||
|
||||
// UNIQUE constraint error - retry with new ID
|
||||
if attempt == maxRetries-1 {
|
||||
// Last attempt failed
|
||||
return "", fmt.Errorf("failed to create renamed issue with collision resolution after %d retries: %w", maxRetries, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Note: We don't update text references here because it would be too expensive
|
||||
// to scan all issues during every import. Text references to the old ID will
|
||||
// eventually be cleaned up by manual reference updates or remain as stale.
|
||||
// This is acceptable because the old ID no longer exists in the system.
|
||||
|
||||
|
||||
return oldID, nil
|
||||
*/
|
||||
}
|
||||
@@ -462,15 +475,20 @@ func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get DB issues: %w", err)
|
||||
}
|
||||
|
||||
|
||||
dbByHash := buildHashMap(dbIssues)
|
||||
dbByID := buildIDMap(dbIssues)
|
||||
|
||||
|
||||
// Build external_ref map for O(1) lookup
|
||||
dbByExternalRef := make(map[string]*types.Issue)
|
||||
for _, issue := range dbIssues {
|
||||
if issue.ExternalRef != nil && *issue.ExternalRef != "" {
|
||||
dbByExternalRef[*issue.ExternalRef] = issue
|
||||
if linear.IsLinearExternalRef(*issue.ExternalRef) {
|
||||
if canonical, ok := linear.CanonicalizeLinearExternalRef(*issue.ExternalRef); ok {
|
||||
dbByExternalRef[canonical] = issue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,7 +542,7 @@ func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues
|
||||
result.Unchanged++
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// Build updates map
|
||||
updates := make(map[string]interface{})
|
||||
updates["title"] = incoming.Title
|
||||
@@ -536,19 +554,19 @@ func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues
|
||||
updates["acceptance_criteria"] = incoming.AcceptanceCriteria
|
||||
updates["notes"] = incoming.Notes
|
||||
updates["closed_at"] = incoming.ClosedAt
|
||||
|
||||
|
||||
if incoming.Assignee != "" {
|
||||
updates["assignee"] = incoming.Assignee
|
||||
updates["assignee"] = incoming.Assignee
|
||||
} else {
|
||||
updates["assignee"] = nil
|
||||
updates["assignee"] = nil
|
||||
}
|
||||
|
||||
|
||||
if incoming.ExternalRef != nil && *incoming.ExternalRef != "" {
|
||||
updates["external_ref"] = *incoming.ExternalRef
|
||||
updates["external_ref"] = *incoming.ExternalRef
|
||||
} else {
|
||||
updates["external_ref"] = nil
|
||||
}
|
||||
|
||||
updates["external_ref"] = nil
|
||||
}
|
||||
|
||||
// Only update if data actually changed
|
||||
if IssueDataChanged(existing, updates) {
|
||||
if err := sqliteStore.UpdateIssue(ctx, existing.ID, updates, "import"); err != nil {
|
||||
@@ -617,7 +635,7 @@ func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues
|
||||
result.Unchanged++
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// Build updates map
|
||||
updates := make(map[string]interface{})
|
||||
updates["title"] = incoming.Title
|
||||
@@ -628,19 +646,19 @@ func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues
|
||||
updates["design"] = incoming.Design
|
||||
updates["acceptance_criteria"] = incoming.AcceptanceCriteria
|
||||
updates["notes"] = incoming.Notes
|
||||
updates["closed_at"] = incoming.ClosedAt
|
||||
updates["closed_at"] = incoming.ClosedAt
|
||||
|
||||
if incoming.Assignee != "" {
|
||||
updates["assignee"] = incoming.Assignee
|
||||
updates["assignee"] = incoming.Assignee
|
||||
} else {
|
||||
updates["assignee"] = nil
|
||||
}
|
||||
updates["assignee"] = nil
|
||||
}
|
||||
|
||||
if incoming.ExternalRef != nil && *incoming.ExternalRef != "" {
|
||||
updates["external_ref"] = *incoming.ExternalRef
|
||||
updates["external_ref"] = *incoming.ExternalRef
|
||||
} else {
|
||||
updates["external_ref"] = nil
|
||||
}
|
||||
updates["external_ref"] = nil
|
||||
}
|
||||
|
||||
// Only update if data actually changed
|
||||
if IssueDataChanged(existingWithID, updates) {
|
||||
@@ -660,62 +678,62 @@ func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out orphaned issues if orphan_handling is set to skip (bd-ckej)
|
||||
// Pre-filter before batch creation to prevent orphans from being created then ID-cleared
|
||||
if opts.OrphanHandling == sqlite.OrphanSkip {
|
||||
var filteredNewIssues []*types.Issue
|
||||
for _, issue := range newIssues {
|
||||
// Check if this is a hierarchical child whose parent doesn't exist
|
||||
if strings.Contains(issue.ID, ".") {
|
||||
lastDot := strings.LastIndex(issue.ID, ".")
|
||||
parentID := issue.ID[:lastDot]
|
||||
|
||||
// Check if parent exists in either existing DB issues or in newIssues batch
|
||||
var parentExists bool
|
||||
for _, dbIssue := range dbIssues {
|
||||
if dbIssue.ID == parentID {
|
||||
parentExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !parentExists {
|
||||
for _, newIssue := range newIssues {
|
||||
if newIssue.ID == parentID {
|
||||
// Filter out orphaned issues if orphan_handling is set to skip (bd-ckej)
|
||||
// Pre-filter before batch creation to prevent orphans from being created then ID-cleared
|
||||
if opts.OrphanHandling == sqlite.OrphanSkip {
|
||||
var filteredNewIssues []*types.Issue
|
||||
for _, issue := range newIssues {
|
||||
// Check if this is a hierarchical child whose parent doesn't exist
|
||||
if strings.Contains(issue.ID, ".") {
|
||||
lastDot := strings.LastIndex(issue.ID, ".")
|
||||
parentID := issue.ID[:lastDot]
|
||||
|
||||
// Check if parent exists in either existing DB issues or in newIssues batch
|
||||
var parentExists bool
|
||||
for _, dbIssue := range dbIssues {
|
||||
if dbIssue.ID == parentID {
|
||||
parentExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !parentExists {
|
||||
for _, newIssue := range newIssues {
|
||||
if newIssue.ID == parentID {
|
||||
parentExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !parentExists {
|
||||
// Skip this orphaned issue
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !parentExists {
|
||||
// Skip this orphaned issue
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
filteredNewIssues = append(filteredNewIssues, issue)
|
||||
}
|
||||
filteredNewIssues = append(filteredNewIssues, issue)
|
||||
newIssues = filteredNewIssues
|
||||
}
|
||||
newIssues = filteredNewIssues
|
||||
}
|
||||
|
||||
// Batch create all new issues
|
||||
// Sort by hierarchy depth to ensure parents are created before children
|
||||
if len(newIssues) > 0 {
|
||||
sort.Slice(newIssues, func(i, j int) bool {
|
||||
depthI := strings.Count(newIssues[i].ID, ".")
|
||||
depthJ := strings.Count(newIssues[j].ID, ".")
|
||||
// Batch create all new issues
|
||||
// Sort by hierarchy depth to ensure parents are created before children
|
||||
if len(newIssues) > 0 {
|
||||
sort.Slice(newIssues, func(i, j int) bool {
|
||||
depthI := strings.Count(newIssues[i].ID, ".")
|
||||
depthJ := strings.Count(newIssues[j].ID, ".")
|
||||
if depthI != depthJ {
|
||||
return depthI < depthJ // Shallower first
|
||||
}
|
||||
return newIssues[i].ID < newIssues[j].ID // Stable sort
|
||||
})
|
||||
return depthI < depthJ // Shallower first
|
||||
}
|
||||
return newIssues[i].ID < newIssues[j].ID // Stable sort
|
||||
})
|
||||
|
||||
// Create in batches by depth level (max depth 3)
|
||||
// Create in batches by depth level (max depth 3)
|
||||
for depth := 0; depth <= 3; depth++ {
|
||||
var batchForDepth []*types.Issue
|
||||
for _, issue := range newIssues {
|
||||
if strings.Count(issue.ID, ".") == depth {
|
||||
batchForDepth = append(batchForDepth, issue)
|
||||
var batchForDepth []*types.Issue
|
||||
for _, issue := range newIssues {
|
||||
if strings.Count(issue.ID, ".") == depth {
|
||||
batchForDepth = append(batchForDepth, issue)
|
||||
}
|
||||
}
|
||||
if len(batchForDepth) > 0 {
|
||||
@@ -876,7 +894,7 @@ func GetPrefixList(prefixes map[string]int) []string {
|
||||
|
||||
func validateNoDuplicateExternalRefs(issues []*types.Issue, clearDuplicates bool, result *Result) error {
|
||||
seen := make(map[string][]string)
|
||||
|
||||
|
||||
for _, issue := range issues {
|
||||
if issue.ExternalRef != nil && *issue.ExternalRef != "" {
|
||||
ref := *issue.ExternalRef
|
||||
@@ -910,7 +928,7 @@ func validateNoDuplicateExternalRefs(issues []*types.Issue, clearDuplicates bool
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
sort.Strings(duplicates)
|
||||
return fmt.Errorf("batch import contains duplicate external_ref values:\n%s\n\nUse --clear-duplicate-external-refs to automatically clear duplicates", strings.Join(duplicates, "\n"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user