Files
beads/internal/linear/types.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

253 lines
7.5 KiB
Go

// Package linear provides client and data types for the Linear GraphQL API.
//
// This package handles all interactions with Linear's issue tracking system,
// including fetching, creating, and updating issues. It provides bidirectional
// mapping between Linear's data model and Beads' internal types.
package linear
import (
"net/http"
"time"
)
// API configuration constants.
const (
// DefaultAPIEndpoint is the Linear GraphQL API endpoint.
DefaultAPIEndpoint = "https://api.linear.app/graphql"
// DefaultTimeout is the default HTTP request timeout.
DefaultTimeout = 30 * time.Second
// MaxRetries is the maximum number of retries for rate-limited requests.
MaxRetries = 3
// RetryDelay is the base delay between retries (exponential backoff).
RetryDelay = time.Second
// MaxPageSize is the maximum number of issues to fetch per page.
MaxPageSize = 100
)
// Client provides methods to interact with the Linear GraphQL API.
type Client struct {
APIKey string
TeamID string
Endpoint string // GraphQL endpoint URL (defaults to DefaultAPIEndpoint)
HTTPClient *http.Client
}
// GraphQLRequest represents a GraphQL request payload.
type GraphQLRequest struct {
Query string `json:"query"`
Variables map[string]interface{} `json:"variables,omitempty"`
}
// GraphQLResponse represents a generic GraphQL response.
type GraphQLResponse struct {
Data []byte `json:"data"`
Errors []GraphQLError `json:"errors,omitempty"`
}
// GraphQLError represents a GraphQL error.
type GraphQLError struct {
Message string `json:"message"`
Path []string `json:"path,omitempty"`
Extensions struct {
Code string `json:"code,omitempty"`
} `json:"extensions,omitempty"`
}
// Issue represents an issue from the Linear API.
type Issue struct {
ID string `json:"id"`
Identifier string `json:"identifier"` // e.g., "TEAM-123"
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
Priority int `json:"priority"` // 0=no priority, 1=urgent, 2=high, 3=medium, 4=low
State *State `json:"state"`
Assignee *User `json:"assignee"`
Labels *Labels `json:"labels"`
Parent *Parent `json:"parent,omitempty"`
Relations *Relations `json:"relations,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
CompletedAt string `json:"completedAt,omitempty"`
}
// State represents a workflow state in Linear.
type State struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "backlog", "unstarted", "started", "completed", "canceled"
}
// User represents a user in Linear.
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
}
// Labels represents paginated labels on an issue.
type Labels struct {
Nodes []Label `json:"nodes"`
}
// Label represents a label in Linear.
type Label struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Parent represents a parent issue reference.
type Parent struct {
ID string `json:"id"`
Identifier string `json:"identifier"`
}
// Relation represents a relation between issues in Linear.
type Relation struct {
ID string `json:"id"`
Type string `json:"type"` // "blocks", "blockedBy", "duplicate", "related"
RelatedIssue struct {
ID string `json:"id"`
Identifier string `json:"identifier"`
} `json:"relatedIssue"`
}
// Relations wraps the nodes array for relations.
type Relations struct {
Nodes []Relation `json:"nodes"`
}
// TeamStates represents workflow states for a team.
type TeamStates struct {
ID string `json:"id"`
States *StatesWrapper `json:"states"`
}
// StatesWrapper wraps the nodes array for states.
type StatesWrapper struct {
Nodes []State `json:"nodes"`
}
// IssuesResponse represents the response from issues query.
type IssuesResponse struct {
Issues struct {
Nodes []Issue `json:"nodes"`
PageInfo struct {
HasNextPage bool `json:"hasNextPage"`
EndCursor string `json:"endCursor"`
} `json:"pageInfo"`
} `json:"issues"`
}
// IssueCreateResponse represents the response from issueCreate mutation.
type IssueCreateResponse struct {
IssueCreate struct {
Success bool `json:"success"`
Issue Issue `json:"issue"`
} `json:"issueCreate"`
}
// IssueUpdateResponse represents the response from issueUpdate mutation.
type IssueUpdateResponse struct {
IssueUpdate struct {
Success bool `json:"success"`
Issue Issue `json:"issue"`
} `json:"issueUpdate"`
}
// TeamResponse represents the response from team query.
type TeamResponse struct {
Team TeamStates `json:"team"`
}
// SyncStats tracks statistics for a Linear sync operation.
type SyncStats struct {
Pulled int `json:"pulled"`
Pushed int `json:"pushed"`
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Errors int `json:"errors"`
Conflicts int `json:"conflicts"`
}
// SyncResult represents the result of a Linear sync operation.
type SyncResult struct {
Success bool `json:"success"`
Stats SyncStats `json:"stats"`
LastSync string `json:"last_sync,omitempty"`
Error string `json:"error,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
// PullStats tracks pull operation statistics.
type PullStats struct {
Created int
Updated int
Skipped int
Incremental bool // Whether this was an incremental sync
SyncedSince string // Timestamp we synced since (if incremental)
}
// PushStats tracks push operation statistics.
type PushStats struct {
Created int
Updated int
Skipped int
Errors int
}
// Conflict represents a conflict between local and Linear versions.
// A conflict occurs when both the local and Linear versions have been modified
// since the last sync.
type Conflict struct {
IssueID string // Beads issue ID
LocalUpdated time.Time // When the local version was last modified
LinearUpdated time.Time // When the Linear version was last modified
LinearExternalRef string // URL to the Linear issue
LinearIdentifier string // Linear issue identifier (e.g., "TEAM-123")
LinearInternalID string // Linear's internal UUID (for API updates)
}
// IssueConversion holds the result of converting a Linear issue to Beads.
// It includes the issue and any dependencies that should be created.
type IssueConversion struct {
Issue interface{} // *types.Issue - avoiding circular import
Dependencies []DependencyInfo
}
// DependencyInfo represents a dependency to be created after issue import.
// Stored separately since we need all issues imported before linking dependencies.
type DependencyInfo struct {
FromLinearID string // Linear identifier of the dependent issue (e.g., "TEAM-123")
ToLinearID string // Linear identifier of the dependency target
Type string // Beads dependency type (blocks, related, duplicates, parent-child)
}
// StateCache caches workflow states for the team to avoid repeated API calls.
type StateCache struct {
States []State
StatesByID map[string]State
OpenStateID string // First "unstarted" or "backlog" state
}
// Team represents a team in Linear.
type Team struct {
ID string `json:"id"` // UUID
Name string `json:"name"` // Display name
Key string `json:"key"` // Short key used in issue identifiers (e.g., "ENG")
}
// TeamsResponse represents the response from teams query.
type TeamsResponse struct {
Teams struct {
Nodes []Team `json:"nodes"`
} `json:"teams"`
}