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:
Justin Williams
2025-12-19 20:58:24 -05:00
committed by GitHub
parent a82f393e49
commit df5ceb5d82
13 changed files with 5600 additions and 179 deletions

669
internal/linear/client.go Normal file
View File

@@ -0,0 +1,669 @@
package linear
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/steveyegge/beads/internal/types"
)
// IssuesQuery is the GraphQL query for fetching issues with all required fields.
// Used by both FetchIssues and FetchIssuesSince for consistency.
const IssuesQuery = `
query Issues($filter: IssueFilter!, $first: Int!, $after: String) {
issues(
first: $first
after: $after
filter: $filter
) {
nodes {
id
identifier
title
description
url
priority
state {
id
name
type
}
assignee {
id
name
email
displayName
}
labels {
nodes {
id
name
}
}
parent {
id
identifier
}
relations {
nodes {
id
type
relatedIssue {
id
identifier
}
}
}
createdAt
updatedAt
completedAt
}
pageInfo {
hasNextPage
endCursor
}
}
}
`
// NewClient creates a new Linear client with the given API key and team ID.
func NewClient(apiKey, teamID string) *Client {
return &Client{
APIKey: apiKey,
TeamID: teamID,
Endpoint: DefaultAPIEndpoint,
HTTPClient: &http.Client{
Timeout: DefaultTimeout,
},
}
}
// WithEndpoint returns a new client configured to use the specified endpoint.
// This is useful for testing with mock servers or connecting to self-hosted instances.
func (c *Client) WithEndpoint(endpoint string) *Client {
return &Client{
APIKey: c.APIKey,
TeamID: c.TeamID,
Endpoint: endpoint,
HTTPClient: c.HTTPClient,
}
}
// WithHTTPClient returns a new client configured to use the specified HTTP client.
// This is useful for testing or customizing timeouts and transport settings.
func (c *Client) WithHTTPClient(httpClient *http.Client) *Client {
return &Client{
APIKey: c.APIKey,
TeamID: c.TeamID,
Endpoint: c.Endpoint,
HTTPClient: httpClient,
}
}
// Execute sends a GraphQL request to the Linear API.
// Handles rate limiting with exponential backoff.
func (c *Client) Execute(ctx context.Context, req *GraphQLRequest) (json.RawMessage, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
var lastErr error
for attempt := 0; attempt <= MaxRetries; attempt++ {
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.Endpoint, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", c.APIKey)
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
lastErr = fmt.Errorf("request failed (attempt %d/%d): %w", attempt+1, MaxRetries+1, err)
continue
}
respBody, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("failed to read response (attempt %d/%d): %w", attempt+1, MaxRetries+1, err)
continue
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := RetryDelay * time.Duration(1<<attempt) // Exponential backoff
lastErr = fmt.Errorf("rate limited (attempt %d/%d), retrying after %v", attempt+1, MaxRetries+1, delay)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
continue
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("API error: %s (status %d)", string(respBody), resp.StatusCode)
}
var gqlResp struct {
Data json.RawMessage `json:"data"`
Errors []GraphQLError `json:"errors,omitempty"`
}
if err := json.Unmarshal(respBody, &gqlResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w (body: %s)", err, string(respBody))
}
if len(gqlResp.Errors) > 0 {
errMsgs := make([]string, len(gqlResp.Errors))
for i, e := range gqlResp.Errors {
errMsgs[i] = e.Message
}
return nil, fmt.Errorf("GraphQL errors: %s", strings.Join(errMsgs, "; "))
}
return gqlResp.Data, nil
}
return nil, fmt.Errorf("max retries (%d) exceeded: %w", MaxRetries+1, lastErr)
}
// FetchIssues retrieves issues from Linear with optional filtering by state.
// state can be: "open" (unstarted/started), "closed" (completed/canceled), or "all".
func (c *Client) FetchIssues(ctx context.Context, state string) ([]Issue, error) {
var allIssues []Issue
var cursor string
filter := map[string]interface{}{
"team": map[string]interface{}{
"id": map[string]interface{}{
"eq": c.TeamID,
},
},
}
switch state {
case "open":
filter["state"] = map[string]interface{}{
"type": map[string]interface{}{
"in": []string{"backlog", "unstarted", "started"},
},
}
case "closed":
filter["state"] = map[string]interface{}{
"type": map[string]interface{}{
"in": []string{"completed", "canceled"},
},
}
}
for {
variables := map[string]interface{}{
"filter": filter,
"first": MaxPageSize,
}
if cursor != "" {
variables["after"] = cursor
}
req := &GraphQLRequest{
Query: IssuesQuery,
Variables: variables,
}
data, err := c.Execute(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to fetch issues: %w", err)
}
var issuesResp IssuesResponse
if err := json.Unmarshal(data, &issuesResp); err != nil {
return nil, fmt.Errorf("failed to parse issues response: %w", err)
}
allIssues = append(allIssues, issuesResp.Issues.Nodes...)
if !issuesResp.Issues.PageInfo.HasNextPage {
break
}
cursor = issuesResp.Issues.PageInfo.EndCursor
}
return allIssues, nil
}
// FetchIssuesSince retrieves issues from Linear that have been updated since the given time.
// This enables incremental sync by only fetching issues modified after the last sync.
// The state parameter can be: "open", "closed", or "all".
func (c *Client) FetchIssuesSince(ctx context.Context, state string, since time.Time) ([]Issue, error) {
var allIssues []Issue
var cursor string
// Build the filter with team and updatedAt constraint.
// Linear uses ISO8601 format for date comparisons.
sinceStr := since.UTC().Format(time.RFC3339)
filter := map[string]interface{}{
"team": map[string]interface{}{
"id": map[string]interface{}{
"eq": c.TeamID,
},
},
"updatedAt": map[string]interface{}{
"gte": sinceStr,
},
}
// Add state filter if specified
switch state {
case "open":
filter["state"] = map[string]interface{}{
"type": map[string]interface{}{
"in": []string{"backlog", "unstarted", "started"},
},
}
case "closed":
filter["state"] = map[string]interface{}{
"type": map[string]interface{}{
"in": []string{"completed", "canceled"},
},
}
}
for {
variables := map[string]interface{}{
"filter": filter,
"first": MaxPageSize,
}
if cursor != "" {
variables["after"] = cursor
}
req := &GraphQLRequest{
Query: IssuesQuery,
Variables: variables,
}
data, err := c.Execute(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to fetch issues since %s: %w", sinceStr, err)
}
var issuesResp IssuesResponse
if err := json.Unmarshal(data, &issuesResp); err != nil {
return nil, fmt.Errorf("failed to parse issues response: %w", err)
}
allIssues = append(allIssues, issuesResp.Issues.Nodes...)
if !issuesResp.Issues.PageInfo.HasNextPage {
break
}
cursor = issuesResp.Issues.PageInfo.EndCursor
}
return allIssues, nil
}
// GetTeamStates fetches the workflow states for the configured team.
func (c *Client) GetTeamStates(ctx context.Context) ([]State, error) {
query := `
query TeamStates($teamId: String!) {
team(id: $teamId) {
id
states {
nodes {
id
name
type
}
}
}
}
`
req := &GraphQLRequest{
Query: query,
Variables: map[string]interface{}{
"teamId": c.TeamID,
},
}
data, err := c.Execute(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to fetch team states: %w", err)
}
var teamResp TeamResponse
if err := json.Unmarshal(data, &teamResp); err != nil {
return nil, fmt.Errorf("failed to parse team states response: %w", err)
}
if teamResp.Team.States == nil {
return nil, fmt.Errorf("no states found for team")
}
return teamResp.Team.States.Nodes, nil
}
// CreateIssue creates a new issue in Linear.
func (c *Client) CreateIssue(ctx context.Context, title, description string, priority int, stateID string, labelIDs []string) (*Issue, error) {
query := `
mutation CreateIssue($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue {
id
identifier
title
description
url
priority
state {
id
name
type
}
createdAt
updatedAt
}
}
}
`
input := map[string]interface{}{
"teamId": c.TeamID,
"title": title,
"description": description,
}
if priority > 0 {
input["priority"] = priority
}
if stateID != "" {
input["stateId"] = stateID
}
if len(labelIDs) > 0 {
input["labelIds"] = labelIDs
}
req := &GraphQLRequest{
Query: query,
Variables: map[string]interface{}{
"input": input,
},
}
data, err := c.Execute(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to create issue: %w", err)
}
var createResp IssueCreateResponse
if err := json.Unmarshal(data, &createResp); err != nil {
return nil, fmt.Errorf("failed to parse create response: %w", err)
}
if !createResp.IssueCreate.Success {
return nil, fmt.Errorf("issue creation reported as unsuccessful")
}
return &createResp.IssueCreate.Issue, nil
}
// UpdateIssue updates an existing issue in Linear.
func (c *Client) UpdateIssue(ctx context.Context, issueID string, updates map[string]interface{}) (*Issue, error) {
query := `
mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue {
id
identifier
title
description
url
priority
state {
id
name
type
}
updatedAt
}
}
}
`
req := &GraphQLRequest{
Query: query,
Variables: map[string]interface{}{
"id": issueID,
"input": updates,
},
}
data, err := c.Execute(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to update issue: %w", err)
}
var updateResp IssueUpdateResponse
if err := json.Unmarshal(data, &updateResp); err != nil {
return nil, fmt.Errorf("failed to parse update response: %w", err)
}
if !updateResp.IssueUpdate.Success {
return nil, fmt.Errorf("issue update reported as unsuccessful")
}
return &updateResp.IssueUpdate.Issue, nil
}
// FetchIssueByIdentifier retrieves a single issue from Linear by its identifier (e.g., "TEAM-123").
// Returns nil if the issue is not found.
func (c *Client) FetchIssueByIdentifier(ctx context.Context, identifier string) (*Issue, error) {
query := `
query IssueByIdentifier($filter: IssueFilter!) {
issues(filter: $filter, first: 1) {
nodes {
id
identifier
title
description
url
priority
state {
id
name
type
}
assignee {
id
name
email
displayName
}
labels {
nodes {
id
name
}
}
createdAt
updatedAt
completedAt
}
}
}
`
// Build filter to search by identifier number and team prefix
// Linear identifiers look like "TEAM-123", we filter by number
// and validate the full identifier in the results
variables := map[string]interface{}{
"filter": map[string]interface{}{
"team": map[string]interface{}{
"id": map[string]interface{}{
"eq": c.TeamID,
},
},
},
}
// Extract the issue number from identifier (e.g., "123" from "TEAM-123")
parts := strings.Split(identifier, "-")
if len(parts) >= 2 {
if number, err := strconv.Atoi(parts[len(parts)-1]); err == nil {
// Add number filter for more precise matching
variables["filter"].(map[string]interface{})["number"] = map[string]interface{}{
"eq": number,
}
}
}
req := &GraphQLRequest{
Query: query,
Variables: variables,
}
data, err := c.Execute(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to fetch issue by identifier: %w", err)
}
var issuesResp IssuesResponse
if err := json.Unmarshal(data, &issuesResp); err != nil {
return nil, fmt.Errorf("failed to parse issues response: %w", err)
}
// Find the exact match by identifier (in case of partial matches)
for _, issue := range issuesResp.Issues.Nodes {
if issue.Identifier == identifier {
return &issue, nil
}
}
return nil, nil // Issue not found
}
// BuildStateCache fetches and caches team states.
func BuildStateCache(ctx context.Context, client *Client) (*StateCache, error) {
states, err := client.GetTeamStates(ctx)
if err != nil {
return nil, err
}
cache := &StateCache{
States: states,
StatesByID: make(map[string]State),
}
for _, s := range states {
cache.StatesByID[s.ID] = s
if cache.OpenStateID == "" && (s.Type == "unstarted" || s.Type == "backlog") {
cache.OpenStateID = s.ID
}
}
return cache, nil
}
// FindStateForBeadsStatus returns the best Linear state ID for a Beads status.
func (sc *StateCache) FindStateForBeadsStatus(status types.Status) string {
targetType := StatusToLinearStateType(status)
for _, s := range sc.States {
if s.Type == targetType {
return s.ID
}
}
if len(sc.States) > 0 {
return sc.States[0].ID
}
return ""
}
// ExtractLinearIdentifier extracts the Linear issue identifier (e.g., "TEAM-123") from a Linear URL.
func ExtractLinearIdentifier(url string) string {
// Linear URLs look like: https://linear.app/team/issue/TEAM-123/title
// We want to extract "TEAM-123"
parts := strings.Split(url, "/")
for i, part := range parts {
if part == "issue" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
// CanonicalizeLinearExternalRef returns a stable Linear issue URL without the slug.
// Example: https://linear.app/team/issue/TEAM-123/title -> https://linear.app/team/issue/TEAM-123
// Returns ok=false if the URL isn't a recognizable Linear issue URL.
func CanonicalizeLinearExternalRef(externalRef string) (canonical string, ok bool) {
if externalRef == "" || !IsLinearExternalRef(externalRef) {
return "", false
}
parsed, err := url.Parse(externalRef)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", false
}
segments := strings.Split(parsed.Path, "/")
for i, segment := range segments {
if segment == "issue" && i+1 < len(segments) && segments[i+1] != "" {
path := "/" + strings.Join(segments[1:i+2], "/")
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, path), true
}
}
return "", false
}
// IsLinearExternalRef checks if an external_ref URL is a Linear issue URL.
func IsLinearExternalRef(externalRef string) bool {
return strings.Contains(externalRef, "linear.app/") && strings.Contains(externalRef, "/issue/")
}
// FetchTeams retrieves all teams accessible with the current API key.
// This is useful for discovering the team ID needed for configuration.
func (c *Client) FetchTeams(ctx context.Context) ([]Team, error) {
query := `
query {
teams {
nodes {
id
name
key
}
}
}
`
req := &GraphQLRequest{
Query: query,
}
data, err := c.Execute(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to fetch teams: %w", err)
}
var teamsResp TeamsResponse
if err := json.Unmarshal(data, &teamsResp); err != nil {
return nil, fmt.Errorf("failed to parse teams response: %w", err)
}
return teamsResp.Teams.Nodes, nil
}

View File

@@ -0,0 +1,41 @@
package linear
import "testing"
func TestCanonicalizeLinearExternalRef(t *testing.T) {
tests := []struct {
name string
externalRef string
want string
ok bool
}{
{
name: "slugged url",
externalRef: "https://linear.app/crown-dev/issue/BEA-93/updated-title-for-beads",
want: "https://linear.app/crown-dev/issue/BEA-93",
ok: true,
},
{
name: "canonical url",
externalRef: "https://linear.app/crown-dev/issue/BEA-93",
want: "https://linear.app/crown-dev/issue/BEA-93",
ok: true,
},
{
name: "not linear",
externalRef: "https://example.com/issues/BEA-93",
want: "",
ok: false,
},
}
for _, tt := range tests {
got, ok := CanonicalizeLinearExternalRef(tt.externalRef)
if ok != tt.ok {
t.Fatalf("%s: ok=%v, want %v", tt.name, ok, tt.ok)
}
if got != tt.want {
t.Fatalf("%s: got %q, want %q", tt.name, got, tt.want)
}
}
}

546
internal/linear/mapping.go Normal file
View File

@@ -0,0 +1,546 @@
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
}

View File

@@ -0,0 +1,144 @@
package linear
import (
"testing"
"time"
"github.com/steveyegge/beads/internal/types"
)
func TestGenerateIssueIDs(t *testing.T) {
// Create test issues without IDs
issues := []*types.Issue{
{
Title: "First issue",
Description: "Description 1",
CreatedAt: time.Now(),
},
{
Title: "Second issue",
Description: "Description 2",
CreatedAt: time.Now().Add(-time.Hour),
},
{
Title: "Third issue",
Description: "Description 3",
CreatedAt: time.Now().Add(-2 * time.Hour),
},
}
// Generate IDs
err := GenerateIssueIDs(issues, "test", "linear-import", IDGenerationOptions{})
if err != nil {
t.Fatalf("GenerateIssueIDs failed: %v", err)
}
// Verify all issues have IDs
for i, issue := range issues {
if issue.ID == "" {
t.Errorf("Issue %d has empty ID", i)
}
// Verify prefix
if !hasPrefix(issue.ID, "test-") {
t.Errorf("Issue %d ID '%s' doesn't have prefix 'test-'", i, issue.ID)
}
}
// Verify all IDs are unique
seen := make(map[string]bool)
for i, issue := range issues {
if seen[issue.ID] {
t.Errorf("Duplicate ID found: %s (issue %d)", issue.ID, i)
}
seen[issue.ID] = true
}
}
func TestGenerateIssueIDsPreservesExisting(t *testing.T) {
existingID := "test-existing"
issues := []*types.Issue{
{
ID: existingID,
Title: "Existing issue",
Description: "Has an ID already",
CreatedAt: time.Now(),
},
{
Title: "New issue",
Description: "Needs an ID",
CreatedAt: time.Now(),
},
}
err := GenerateIssueIDs(issues, "test", "linear-import", IDGenerationOptions{})
if err != nil {
t.Fatalf("GenerateIssueIDs failed: %v", err)
}
// First issue should keep its original ID
if issues[0].ID != existingID {
t.Errorf("Existing ID was changed: got %s, want %s", issues[0].ID, existingID)
}
// Second issue should have a new ID
if issues[1].ID == "" {
t.Error("Second issue has empty ID")
}
if issues[1].ID == existingID {
t.Error("Second issue has same ID as first (collision)")
}
}
func TestGenerateIssueIDsNoDuplicates(t *testing.T) {
// Create issues with identical content - should still get unique IDs
now := time.Now()
issues := []*types.Issue{
{
Title: "Same title",
Description: "Same description",
CreatedAt: now,
},
{
Title: "Same title",
Description: "Same description",
CreatedAt: now,
},
}
err := GenerateIssueIDs(issues, "bd", "linear-import", IDGenerationOptions{})
if err != nil {
t.Fatalf("GenerateIssueIDs failed: %v", err)
}
// Both should have IDs
if issues[0].ID == "" || issues[1].ID == "" {
t.Error("One or both issues have empty IDs")
}
// IDs should be different (nonce handles collision)
if issues[0].ID == issues[1].ID {
t.Errorf("Both issues have same ID: %s", issues[0].ID)
}
}
func TestNormalizeIssueForLinearHashCanonicalizesExternalRef(t *testing.T) {
slugged := "https://linear.app/crown-dev/issue/BEA-93/updated-title-for-beads"
canonical := "https://linear.app/crown-dev/issue/BEA-93"
issue := &types.Issue{
Title: "Title",
Description: "Description",
ExternalRef: &slugged,
}
normalized := NormalizeIssueForLinearHash(issue)
if normalized.ExternalRef == nil {
t.Fatal("expected external_ref to be present")
}
if *normalized.ExternalRef != canonical {
t.Fatalf("expected canonical external_ref %q, got %q", canonical, *normalized.ExternalRef)
}
}
func hasPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}

252
internal/linear/types.go Normal file
View File

@@ -0,0 +1,252 @@
// 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"`
}