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:
669
internal/linear/client.go
Normal file
669
internal/linear/client.go
Normal 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
|
||||
}
|
||||
41
internal/linear/client_test.go
Normal file
41
internal/linear/client_test.go
Normal 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
546
internal/linear/mapping.go
Normal 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
|
||||
}
|
||||
144
internal/linear/mapping_test.go
Normal file
144
internal/linear/mapping_test.go
Normal 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
252
internal/linear/types.go
Normal 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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user