Files
beads/internal/linear/mapping.go
Justin Williams df5ceb5d82 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
2025-12-19 17:58:24 -08:00

547 lines
15 KiB
Go

package linear
import (
"fmt"
"strings"
"time"
"github.com/steveyegge/beads/internal/idgen"
"github.com/steveyegge/beads/internal/types"
)
// IDGenerationOptions configures Linear hash ID generation.
type IDGenerationOptions struct {
BaseLength int // Starting hash length (3-8)
MaxLength int // Maximum hash length (3-8)
UsedIDs map[string]bool // Pre-populated set to avoid collisions (e.g., DB IDs)
}
// BuildLinearDescription formats a Beads issue for Linear's description field.
// This mirrors the payload used during push to keep hash comparisons consistent.
func BuildLinearDescription(issue *types.Issue) string {
description := issue.Description
if issue.AcceptanceCriteria != "" {
description += "\n\n## Acceptance Criteria\n" + issue.AcceptanceCriteria
}
if issue.Design != "" {
description += "\n\n## Design\n" + issue.Design
}
if issue.Notes != "" {
description += "\n\n## Notes\n" + issue.Notes
}
return description
}
// NormalizeIssueForLinearHash returns a copy of the issue using Linear's description
// formatting and clears fields not present in Linear's model to avoid false conflicts.
func NormalizeIssueForLinearHash(issue *types.Issue) *types.Issue {
normalized := *issue
normalized.Description = BuildLinearDescription(issue)
normalized.AcceptanceCriteria = ""
normalized.Design = ""
normalized.Notes = ""
if normalized.ExternalRef != nil && IsLinearExternalRef(*normalized.ExternalRef) {
if canonical, ok := CanonicalizeLinearExternalRef(*normalized.ExternalRef); ok {
normalized.ExternalRef = &canonical
}
}
return &normalized
}
// GenerateIssueIDs generates unique hash-based IDs for issues that don't have one.
// Tracks used IDs to prevent collisions within the batch (and optionally against existing IDs).
// The creator parameter is used as part of the hash input (e.g., "linear-import").
func GenerateIssueIDs(issues []*types.Issue, prefix, creator string, opts IDGenerationOptions) error {
usedIDs := opts.UsedIDs
if usedIDs == nil {
usedIDs = make(map[string]bool)
}
baseLength := opts.BaseLength
if baseLength == 0 {
baseLength = 6
}
maxLength := opts.MaxLength
if maxLength == 0 {
maxLength = 8
}
if baseLength < 3 {
baseLength = 3
}
if maxLength > 8 {
maxLength = 8
}
if baseLength > maxLength {
baseLength = maxLength
}
// First pass: record existing IDs
for _, issue := range issues {
if issue.ID != "" {
usedIDs[issue.ID] = true
}
}
// Second pass: generate IDs for issues without one
for _, issue := range issues {
if issue.ID != "" {
continue // Already has an ID
}
var generated bool
for length := baseLength; length <= maxLength && !generated; length++ {
for nonce := 0; nonce < 10; nonce++ {
candidate := idgen.GenerateHashID(
prefix,
issue.Title,
issue.Description,
creator,
issue.CreatedAt,
length,
nonce,
)
if !usedIDs[candidate] {
issue.ID = candidate
usedIDs[candidate] = true
generated = true
break
}
}
}
if !generated {
return fmt.Errorf("failed to generate unique ID for issue '%s' after trying lengths %d-%d with 10 nonces each",
issue.Title, baseLength, maxLength)
}
}
return nil
}
// MappingConfig holds configurable mappings between Linear and Beads.
// All maps use lowercase keys for case-insensitive matching.
type MappingConfig struct {
// PriorityMap maps Linear priority (0-4) to Beads priority (0-4).
// Key is Linear priority as string, value is Beads priority.
PriorityMap map[string]int
// StateMap maps Linear state types/names to Beads statuses.
// Key is lowercase state type or name, value is Beads status string.
StateMap map[string]string
// LabelTypeMap maps Linear label names to Beads issue types.
// Key is lowercase label name, value is Beads issue type.
LabelTypeMap map[string]string
// RelationMap maps Linear relation types to Beads dependency types.
// Key is Linear relation type, value is Beads dependency type.
RelationMap map[string]string
}
// DefaultMappingConfig returns sensible default mappings.
func DefaultMappingConfig() *MappingConfig {
return &MappingConfig{
// Linear priority: 0=none, 1=urgent, 2=high, 3=medium, 4=low
// Beads priority: 0=critical, 1=high, 2=medium, 3=low, 4=backlog
PriorityMap: map[string]int{
"0": 4, // No priority -> Backlog
"1": 0, // Urgent -> Critical
"2": 1, // High -> High
"3": 2, // Medium -> Medium
"4": 3, // Low -> Low
},
// Linear state types: backlog, unstarted, started, completed, canceled
StateMap: map[string]string{
"backlog": "open",
"unstarted": "open",
"started": "in_progress",
"completed": "closed",
"canceled": "closed",
},
// Label patterns for issue type inference
LabelTypeMap: map[string]string{
"bug": "bug",
"defect": "bug",
"feature": "feature",
"enhancement": "feature",
"epic": "epic",
"chore": "chore",
"maintenance": "chore",
"task": "task",
},
// Linear relation types to Beads dependency types
RelationMap: map[string]string{
"blocks": "blocks",
"blockedBy": "blocks", // Inverse: the related issue blocks this one
"duplicate": "duplicates",
"related": "related",
},
}
}
// ConfigLoader is an interface for loading configuration values.
// This allows the mapping package to be decoupled from the storage layer.
type ConfigLoader interface {
GetAllConfig() (map[string]string, error)
}
// LoadMappingConfig loads mapping configuration from a config loader.
// Config keys follow the pattern: linear.<category>_map.<key> = <value>
// Examples:
//
// linear.priority_map.0 = 4 (Linear "no priority" -> Beads backlog)
// linear.state_map.started = in_progress
// linear.label_type_map.bug = bug
// linear.relation_map.blocks = blocks
func LoadMappingConfig(loader ConfigLoader) *MappingConfig {
config := DefaultMappingConfig()
if loader == nil {
return config
}
// Load all config keys and filter for linear mappings
allConfig, err := loader.GetAllConfig()
if err != nil {
return config
}
for key, value := range allConfig {
// Parse priority mappings: linear.priority_map.<linear_priority>
if strings.HasPrefix(key, "linear.priority_map.") {
linearPriority := strings.TrimPrefix(key, "linear.priority_map.")
if beadsPriority, err := parseIntValue(value); err == nil {
config.PriorityMap[linearPriority] = beadsPriority
}
}
// Parse state mappings: linear.state_map.<state_type_or_name>
if strings.HasPrefix(key, "linear.state_map.") {
stateKey := strings.ToLower(strings.TrimPrefix(key, "linear.state_map."))
config.StateMap[stateKey] = value
}
// Parse label-to-type mappings: linear.label_type_map.<label_name>
if strings.HasPrefix(key, "linear.label_type_map.") {
labelKey := strings.ToLower(strings.TrimPrefix(key, "linear.label_type_map."))
config.LabelTypeMap[labelKey] = value
}
// Parse relation mappings: linear.relation_map.<relation_type>
if strings.HasPrefix(key, "linear.relation_map.") {
relationType := strings.TrimPrefix(key, "linear.relation_map.")
config.RelationMap[relationType] = value
}
}
return config
}
// parseIntValue safely parses an integer from a string config value.
func parseIntValue(s string) (int, error) {
var v int
_, err := fmt.Sscanf(s, "%d", &v)
return v, err
}
// PriorityToBeads maps Linear priority (0-4) to Beads priority (0-4).
// Linear: 0=no priority, 1=urgent, 2=high, 3=medium, 4=low
// Beads: 0=critical, 1=high, 2=medium, 3=low, 4=backlog
// Uses configurable mapping from linear.priority_map.* config.
func PriorityToBeads(linearPriority int, config *MappingConfig) int {
key := fmt.Sprintf("%d", linearPriority)
if beadsPriority, ok := config.PriorityMap[key]; ok {
return beadsPriority
}
// Fallback to default mapping if not configured
return 2 // Default to Medium
}
// PriorityToLinear maps Beads priority (0-4) to Linear priority (0-4).
// Uses configurable mapping by inverting linear.priority_map.* config.
func PriorityToLinear(beadsPriority int, config *MappingConfig) int {
// Build inverse map from config
inverseMap := make(map[int]int)
for linearKey, beadsVal := range config.PriorityMap {
var linearVal int
if _, err := fmt.Sscanf(linearKey, "%d", &linearVal); err == nil {
inverseMap[beadsVal] = linearVal
}
}
if linearPriority, ok := inverseMap[beadsPriority]; ok {
return linearPriority
}
// Fallback to default mapping if not found
return 3 // Default to Medium
}
// StateToBeadsStatus maps Linear state type to Beads status.
// Checks both state type (backlog, unstarted, etc.) and state name for custom workflows.
// Uses configurable mapping from linear.state_map.* config.
func StateToBeadsStatus(state *State, config *MappingConfig) types.Status {
if state == nil {
return types.StatusOpen
}
// First, try to match by state type (preferred)
stateType := strings.ToLower(state.Type)
if statusStr, ok := config.StateMap[stateType]; ok {
return ParseBeadsStatus(statusStr)
}
// Then try to match by state name (for custom workflow states)
stateName := strings.ToLower(state.Name)
if statusStr, ok := config.StateMap[stateName]; ok {
return ParseBeadsStatus(statusStr)
}
// Default fallback
return types.StatusOpen
}
// ParseBeadsStatus converts a status string to types.Status.
func ParseBeadsStatus(s string) types.Status {
switch strings.ToLower(s) {
case "open":
return types.StatusOpen
case "in_progress", "in-progress", "inprogress":
return types.StatusInProgress
case "blocked":
return types.StatusBlocked
case "closed":
return types.StatusClosed
default:
return types.StatusOpen
}
}
// StatusToLinearStateType converts Beads status to Linear state type for filtering.
// This is used when pushing issues to Linear to find the appropriate state.
func StatusToLinearStateType(status types.Status) string {
switch status {
case types.StatusOpen:
return "unstarted"
case types.StatusInProgress:
return "started"
case types.StatusBlocked:
return "started" // Linear doesn't have blocked state type
case types.StatusClosed:
return "completed"
default:
return "unstarted"
}
}
// LabelToIssueType infers issue type from label names.
// Uses configurable mapping from linear.label_type_map.* config.
func LabelToIssueType(labels *Labels, config *MappingConfig) types.IssueType {
if labels == nil {
return types.TypeTask
}
for _, label := range labels.Nodes {
labelName := strings.ToLower(label.Name)
// Check exact match first
if issueType, ok := config.LabelTypeMap[labelName]; ok {
return ParseIssueType(issueType)
}
// Check if label contains any mapped keyword
for keyword, issueType := range config.LabelTypeMap {
if strings.Contains(labelName, keyword) {
return ParseIssueType(issueType)
}
}
}
return types.TypeTask // Default
}
// ParseIssueType converts an issue type string to types.IssueType.
func ParseIssueType(s string) types.IssueType {
switch strings.ToLower(s) {
case "bug":
return types.TypeBug
case "feature":
return types.TypeFeature
case "task":
return types.TypeTask
case "epic":
return types.TypeEpic
case "chore":
return types.TypeChore
default:
return types.TypeTask
}
}
// RelationToBeadsDep converts a Linear relation to a Beads dependency type.
// Uses configurable mapping from linear.relation_map.* config.
func RelationToBeadsDep(relationType string, config *MappingConfig) string {
if depType, ok := config.RelationMap[relationType]; ok {
return depType
}
return "related" // Default fallback
}
// IssueToBeads converts a Linear issue to a Beads issue.
func IssueToBeads(li *Issue, config *MappingConfig) *IssueConversion {
createdAt, err := time.Parse(time.RFC3339, li.CreatedAt)
if err != nil {
createdAt = time.Now()
}
updatedAt, err := time.Parse(time.RFC3339, li.UpdatedAt)
if err != nil {
updatedAt = time.Now()
}
issue := &types.Issue{
Title: li.Title,
Description: li.Description,
Priority: PriorityToBeads(li.Priority, config),
IssueType: LabelToIssueType(li.Labels, config),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
// Map state using configurable mapping
issue.Status = StateToBeadsStatus(li.State, config)
if li.CompletedAt != "" {
completedAt, err := time.Parse(time.RFC3339, li.CompletedAt)
if err == nil {
issue.ClosedAt = &completedAt
}
}
if li.Assignee != nil {
if li.Assignee.Email != "" {
issue.Assignee = li.Assignee.Email
} else {
issue.Assignee = li.Assignee.Name
}
}
// Copy labels (bidirectional sync preserves all labels)
if li.Labels != nil {
for _, label := range li.Labels.Nodes {
issue.Labels = append(issue.Labels, label.Name)
}
}
externalRef := li.URL
if canonical, ok := CanonicalizeLinearExternalRef(externalRef); ok {
externalRef = canonical
}
issue.ExternalRef = &externalRef
// Collect dependencies to be created after all issues are imported
var deps []DependencyInfo
// Map parent-child relationship
if li.Parent != nil {
deps = append(deps, DependencyInfo{
FromLinearID: li.Identifier,
ToLinearID: li.Parent.Identifier,
Type: "parent-child",
})
}
// Map relations to dependencies
if li.Relations != nil {
for _, rel := range li.Relations.Nodes {
depType := RelationToBeadsDep(rel.Type, config)
// For "blockedBy", we invert the direction since the related issue blocks this one
if rel.Type == "blockedBy" {
deps = append(deps, DependencyInfo{
FromLinearID: li.Identifier,
ToLinearID: rel.RelatedIssue.Identifier,
Type: depType,
})
continue
}
// For "blocks", the related issue is blocked by this one.
if rel.Type == "blocks" {
deps = append(deps, DependencyInfo{
FromLinearID: rel.RelatedIssue.Identifier,
ToLinearID: li.Identifier,
Type: depType,
})
continue
}
// For "duplicate" and "related", treat this issue as the source.
deps = append(deps, DependencyInfo{
FromLinearID: li.Identifier,
ToLinearID: rel.RelatedIssue.Identifier,
Type: depType,
})
}
}
return &IssueConversion{
Issue: issue,
Dependencies: deps,
}
}
// BuildLinearToLocalUpdates creates an updates map from a Linear issue
// to apply to a local Beads issue. This is used when Linear wins a conflict.
func BuildLinearToLocalUpdates(li *Issue, config *MappingConfig) map[string]interface{} {
updates := make(map[string]interface{})
// Update title
updates["title"] = li.Title
// Update description
updates["description"] = li.Description
// Update priority using configured mapping
updates["priority"] = PriorityToBeads(li.Priority, config)
// Update status using configured mapping
updates["status"] = string(StateToBeadsStatus(li.State, config))
// Update assignee if present
if li.Assignee != nil {
if li.Assignee.Email != "" {
updates["assignee"] = li.Assignee.Email
} else {
updates["assignee"] = li.Assignee.Name
}
} else {
updates["assignee"] = ""
}
// Update labels from Linear
if li.Labels != nil {
var labels []string
for _, label := range li.Labels.Nodes {
labels = append(labels, label.Name)
}
updates["labels"] = labels
}
// Update timestamps
if li.UpdatedAt != "" {
if updatedAt, err := time.Parse(time.RFC3339, li.UpdatedAt); err == nil {
updates["updated_at"] = updatedAt
}
}
// Handle closed state
if li.CompletedAt != "" {
if closedAt, err := time.Parse(time.RFC3339, li.CompletedAt); err == nil {
updates["closed_at"] = closedAt
}
}
return updates
}