* 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
1899 lines
49 KiB
Go
1899 lines
49 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/linear"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
func TestLinearPriorityToBeads(t *testing.T) {
|
|
config := linear.DefaultMappingConfig()
|
|
|
|
tests := []struct {
|
|
name string
|
|
linearPriority int
|
|
wantBeads int
|
|
}{
|
|
{
|
|
name: "no priority maps to backlog",
|
|
linearPriority: 0,
|
|
wantBeads: 4, // Backlog
|
|
},
|
|
{
|
|
name: "urgent maps to critical",
|
|
linearPriority: 1,
|
|
wantBeads: 0, // Critical
|
|
},
|
|
{
|
|
name: "high maps to high",
|
|
linearPriority: 2,
|
|
wantBeads: 1, // High
|
|
},
|
|
{
|
|
name: "medium maps to medium",
|
|
linearPriority: 3,
|
|
wantBeads: 2, // Medium
|
|
},
|
|
{
|
|
name: "low maps to low",
|
|
linearPriority: 4,
|
|
wantBeads: 3, // Low
|
|
},
|
|
{
|
|
name: "unknown priority defaults to medium",
|
|
linearPriority: 99,
|
|
wantBeads: 2, // Default Medium
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := linear.PriorityToBeads(tt.linearPriority, config)
|
|
if got != tt.wantBeads {
|
|
t.Errorf("PriorityToBeads(%d) = %d, want %d",
|
|
tt.linearPriority, got, tt.wantBeads)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLinearPriorityToBeadsCustomConfig(t *testing.T) {
|
|
// Test with custom priority mapping
|
|
config := &linear.MappingConfig{
|
|
PriorityMap: map[string]int{
|
|
"0": 2, // Custom: no priority -> medium
|
|
"1": 1, // Custom: urgent -> high (not critical)
|
|
"2": 2, // high -> medium
|
|
"3": 3, // medium -> low
|
|
"4": 4, // low -> backlog
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
linearPriority int
|
|
wantBeads int
|
|
}{
|
|
{0, 2}, // Custom mapping
|
|
{1, 1}, // Custom mapping
|
|
{2, 2},
|
|
{3, 3},
|
|
{4, 4},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := linear.PriorityToBeads(tt.linearPriority, config)
|
|
if got != tt.wantBeads {
|
|
t.Errorf("PriorityToBeads(%d) with custom config = %d, want %d",
|
|
tt.linearPriority, got, tt.wantBeads)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBeadsPriorityToLinear(t *testing.T) {
|
|
config := linear.DefaultMappingConfig()
|
|
|
|
tests := []struct {
|
|
name string
|
|
beadsPriority int
|
|
wantLinear int
|
|
}{
|
|
{
|
|
name: "critical maps to urgent",
|
|
beadsPriority: 0,
|
|
wantLinear: 1, // Urgent
|
|
},
|
|
{
|
|
name: "high maps to high",
|
|
beadsPriority: 1,
|
|
wantLinear: 2, // High
|
|
},
|
|
{
|
|
name: "medium maps to medium",
|
|
beadsPriority: 2,
|
|
wantLinear: 3, // Medium
|
|
},
|
|
{
|
|
name: "low maps to low",
|
|
beadsPriority: 3,
|
|
wantLinear: 4, // Low
|
|
},
|
|
{
|
|
name: "backlog maps to no priority",
|
|
beadsPriority: 4,
|
|
wantLinear: 0, // No priority
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := linear.PriorityToLinear(tt.beadsPriority, config)
|
|
if got != tt.wantLinear {
|
|
t.Errorf("PriorityToLinear(%d) = %d, want %d",
|
|
tt.beadsPriority, got, tt.wantLinear)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLinearStateToBeadsStatus(t *testing.T) {
|
|
config := linear.DefaultMappingConfig()
|
|
|
|
tests := []struct {
|
|
name string
|
|
state *linear.State
|
|
wantStatus types.Status
|
|
}{
|
|
{
|
|
name: "nil state defaults to open",
|
|
state: nil,
|
|
wantStatus: types.StatusOpen,
|
|
},
|
|
{
|
|
name: "backlog state maps to open",
|
|
state: &linear.State{Type: "backlog", Name: "Backlog"},
|
|
wantStatus: types.StatusOpen,
|
|
},
|
|
{
|
|
name: "unstarted state maps to open",
|
|
state: &linear.State{Type: "unstarted", Name: "Todo"},
|
|
wantStatus: types.StatusOpen,
|
|
},
|
|
{
|
|
name: "started state maps to in_progress",
|
|
state: &linear.State{Type: "started", Name: "In Progress"},
|
|
wantStatus: types.StatusInProgress,
|
|
},
|
|
{
|
|
name: "completed state maps to closed",
|
|
state: &linear.State{Type: "completed", Name: "Done"},
|
|
wantStatus: types.StatusClosed,
|
|
},
|
|
{
|
|
name: "canceled state maps to closed",
|
|
state: &linear.State{Type: "canceled", Name: "Cancelled"},
|
|
wantStatus: types.StatusClosed,
|
|
},
|
|
{
|
|
name: "unknown state type defaults to open",
|
|
state: &linear.State{Type: "unknown", Name: "Unknown State"},
|
|
wantStatus: types.StatusOpen,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := linear.StateToBeadsStatus(tt.state, config)
|
|
if got != tt.wantStatus {
|
|
t.Errorf("StateToBeadsStatus() = %s, want %s", got, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLinearStateToBeadsStatusCustomConfig(t *testing.T) {
|
|
// Test with custom state name mapping for custom workflow states
|
|
// Note: State names are converted to lowercase with spaces preserved
|
|
// So "In Review" -> "in review", "On Hold" -> "on hold"
|
|
config := &linear.MappingConfig{
|
|
StateMap: map[string]string{
|
|
"backlog": "open",
|
|
"unstarted": "open",
|
|
"started": "in_progress",
|
|
"completed": "closed",
|
|
"canceled": "closed",
|
|
"in review": "in_progress", // Custom state name (lowercase with space)
|
|
"on hold": "blocked", // Custom state name (lowercase with space)
|
|
"blocked": "blocked", // Custom state name
|
|
"validating": "in_progress", // Custom state name
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
state *linear.State
|
|
wantStatus types.Status
|
|
}{
|
|
{
|
|
name: "custom in_review state maps to in_progress",
|
|
state: &linear.State{Type: "custom", Name: "In Review"},
|
|
wantStatus: types.StatusInProgress,
|
|
},
|
|
{
|
|
name: "custom on_hold state maps to blocked",
|
|
state: &linear.State{Type: "custom", Name: "On Hold"},
|
|
wantStatus: types.StatusBlocked,
|
|
},
|
|
{
|
|
name: "custom blocked state maps to blocked",
|
|
state: &linear.State{Type: "custom", Name: "Blocked"},
|
|
wantStatus: types.StatusBlocked,
|
|
},
|
|
{
|
|
name: "custom validating state maps to in_progress",
|
|
state: &linear.State{Type: "custom", Name: "Validating"},
|
|
wantStatus: types.StatusInProgress,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := linear.StateToBeadsStatus(tt.state, config)
|
|
if got != tt.wantStatus {
|
|
t.Errorf("StateToBeadsStatus() with custom config = %s, want %s",
|
|
got, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBeadsStatusToLinearStateType(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status types.Status
|
|
wantLinearState string
|
|
}{
|
|
{
|
|
name: "open maps to unstarted",
|
|
status: types.StatusOpen,
|
|
wantLinearState: "unstarted",
|
|
},
|
|
{
|
|
name: "in_progress maps to started",
|
|
status: types.StatusInProgress,
|
|
wantLinearState: "started",
|
|
},
|
|
{
|
|
name: "blocked maps to started (Linear has no blocked)",
|
|
status: types.StatusBlocked,
|
|
wantLinearState: "started",
|
|
},
|
|
{
|
|
name: "closed maps to completed",
|
|
status: types.StatusClosed,
|
|
wantLinearState: "completed",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := linear.StatusToLinearStateType(tt.status)
|
|
if got != tt.wantLinearState {
|
|
t.Errorf("StatusToLinearStateType(%s) = %s, want %s",
|
|
tt.status, got, tt.wantLinearState)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLinearLabelToIssueType(t *testing.T) {
|
|
config := linear.DefaultMappingConfig()
|
|
|
|
tests := []struct {
|
|
name string
|
|
labels *linear.Labels
|
|
wantType types.IssueType
|
|
}{
|
|
{
|
|
name: "nil labels defaults to task",
|
|
labels: nil,
|
|
wantType: types.TypeTask,
|
|
},
|
|
{
|
|
name: "empty labels defaults to task",
|
|
labels: &linear.Labels{Nodes: []linear.Label{}},
|
|
wantType: types.TypeTask,
|
|
},
|
|
{
|
|
name: "bug label maps to bug type",
|
|
labels: &linear.Labels{
|
|
Nodes: []linear.Label{{Name: "bug"}},
|
|
},
|
|
wantType: types.TypeBug,
|
|
},
|
|
{
|
|
name: "Bug (capitalized) label maps to bug type",
|
|
labels: &linear.Labels{
|
|
Nodes: []linear.Label{{Name: "Bug"}},
|
|
},
|
|
wantType: types.TypeBug,
|
|
},
|
|
{
|
|
name: "defect label maps to bug type",
|
|
labels: &linear.Labels{
|
|
Nodes: []linear.Label{{Name: "defect"}},
|
|
},
|
|
wantType: types.TypeBug,
|
|
},
|
|
{
|
|
name: "feature label maps to feature type",
|
|
labels: &linear.Labels{
|
|
Nodes: []linear.Label{{Name: "feature"}},
|
|
},
|
|
wantType: types.TypeFeature,
|
|
},
|
|
{
|
|
name: "enhancement label maps to feature type",
|
|
labels: &linear.Labels{
|
|
Nodes: []linear.Label{{Name: "enhancement"}},
|
|
},
|
|
wantType: types.TypeFeature,
|
|
},
|
|
{
|
|
name: "epic label maps to epic type",
|
|
labels: &linear.Labels{
|
|
Nodes: []linear.Label{{Name: "epic"}},
|
|
},
|
|
wantType: types.TypeEpic,
|
|
},
|
|
{
|
|
name: "chore label maps to chore type",
|
|
labels: &linear.Labels{
|
|
Nodes: []linear.Label{{Name: "chore"}},
|
|
},
|
|
wantType: types.TypeChore,
|
|
},
|
|
{
|
|
name: "maintenance label maps to chore type",
|
|
labels: &linear.Labels{
|
|
Nodes: []linear.Label{{Name: "maintenance"}},
|
|
},
|
|
wantType: types.TypeChore,
|
|
},
|
|
{
|
|
name: "task label maps to task type",
|
|
labels: &linear.Labels{
|
|
Nodes: []linear.Label{{Name: "task"}},
|
|
},
|
|
wantType: types.TypeTask,
|
|
},
|
|
{
|
|
name: "first matching label wins",
|
|
labels: &linear.Labels{
|
|
Nodes: []linear.Label{
|
|
{Name: "bug"},
|
|
{Name: "feature"},
|
|
},
|
|
},
|
|
wantType: types.TypeBug,
|
|
},
|
|
{
|
|
name: "label containing keyword matches",
|
|
labels: &linear.Labels{
|
|
Nodes: []linear.Label{{Name: "critical-bug"}},
|
|
},
|
|
wantType: types.TypeBug, // Contains "bug"
|
|
},
|
|
{
|
|
name: "unrecognized label defaults to task",
|
|
labels: &linear.Labels{
|
|
Nodes: []linear.Label{{Name: "documentation"}, {Name: "urgent"}},
|
|
},
|
|
wantType: types.TypeTask,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := linear.LabelToIssueType(tt.labels, config)
|
|
if got != tt.wantType {
|
|
t.Errorf("LabelToIssueType() = %s, want %s", got, tt.wantType)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLinearLabelToIssueTypeCustomConfig(t *testing.T) {
|
|
// Test with custom label-to-type mapping
|
|
config := &linear.MappingConfig{
|
|
LabelTypeMap: map[string]string{
|
|
"incident": "bug",
|
|
"improvement": "feature",
|
|
"tech-debt": "chore",
|
|
"story": "feature",
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
labels *linear.Labels
|
|
wantType types.IssueType
|
|
}{
|
|
{
|
|
name: "custom incident label maps to bug",
|
|
labels: &linear.Labels{
|
|
Nodes: []linear.Label{{Name: "incident"}},
|
|
},
|
|
wantType: types.TypeBug,
|
|
},
|
|
{
|
|
name: "custom improvement label maps to feature",
|
|
labels: &linear.Labels{
|
|
Nodes: []linear.Label{{Name: "improvement"}},
|
|
},
|
|
wantType: types.TypeFeature,
|
|
},
|
|
{
|
|
name: "custom tech-debt label maps to chore",
|
|
labels: &linear.Labels{
|
|
Nodes: []linear.Label{{Name: "tech-debt"}},
|
|
},
|
|
wantType: types.TypeChore,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := linear.LabelToIssueType(tt.labels, config)
|
|
if got != tt.wantType {
|
|
t.Errorf("LabelToIssueType() with custom config = %s, want %s",
|
|
got, tt.wantType)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLinearRelationToBeadsDep(t *testing.T) {
|
|
config := linear.DefaultMappingConfig()
|
|
|
|
tests := []struct {
|
|
name string
|
|
relationType string
|
|
wantDepType string
|
|
}{
|
|
{
|
|
name: "blocks relation maps to blocks",
|
|
relationType: "blocks",
|
|
wantDepType: "blocks",
|
|
},
|
|
{
|
|
name: "blockedBy relation maps to blocks",
|
|
relationType: "blockedBy",
|
|
wantDepType: "blocks",
|
|
},
|
|
{
|
|
name: "duplicate relation maps to duplicates",
|
|
relationType: "duplicate",
|
|
wantDepType: "duplicates",
|
|
},
|
|
{
|
|
name: "related relation maps to related",
|
|
relationType: "related",
|
|
wantDepType: "related",
|
|
},
|
|
{
|
|
name: "unknown relation defaults to related",
|
|
relationType: "unknown",
|
|
wantDepType: "related",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := linear.RelationToBeadsDep(tt.relationType, config)
|
|
if got != tt.wantDepType {
|
|
t.Errorf("RelationToBeadsDep(%s) = %s, want %s",
|
|
tt.relationType, got, tt.wantDepType)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLinearRelationToBeadsDepCustomConfig(t *testing.T) {
|
|
// Test with custom relation mapping
|
|
config := &linear.MappingConfig{
|
|
RelationMap: map[string]string{
|
|
"blocks": "blocks",
|
|
"blockedBy": "blocks",
|
|
"duplicate": "related", // Custom: duplicates -> related
|
|
"related": "related",
|
|
"causes": "discovered-from", // Custom relation type
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
relationType string
|
|
wantDepType string
|
|
}{
|
|
{"duplicate", "related"},
|
|
{"causes", "discovered-from"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.relationType, func(t *testing.T) {
|
|
got := linear.RelationToBeadsDep(tt.relationType, config)
|
|
if got != tt.wantDepType {
|
|
t.Errorf("RelationToBeadsDep(%s) with custom config = %s, want %s",
|
|
tt.relationType, got, tt.wantDepType)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLinearIssueToBeads(t *testing.T) {
|
|
config := linear.DefaultMappingConfig()
|
|
|
|
tests := []struct {
|
|
name string
|
|
linearIssue *linear.Issue
|
|
wantTitle string
|
|
wantStatus types.Status
|
|
wantPriority int
|
|
wantType types.IssueType
|
|
wantAssignee string
|
|
wantDepsCount int
|
|
wantHasExtRef bool
|
|
}{
|
|
{
|
|
name: "basic issue conversion",
|
|
linearIssue: &linear.Issue{
|
|
ID: "uuid-123",
|
|
Identifier: "TEAM-123",
|
|
Title: "Fix login bug",
|
|
Description: "Users cannot login",
|
|
URL: "https://linear.app/team/issue/TEAM-123/fix-login-bug",
|
|
Priority: 1, // Urgent
|
|
State: &linear.State{Type: "started", Name: "In Progress"},
|
|
CreatedAt: "2025-01-15T10:00:00Z",
|
|
UpdatedAt: "2025-01-16T14:30:00Z",
|
|
},
|
|
wantTitle: "Fix login bug",
|
|
wantStatus: types.StatusInProgress,
|
|
wantPriority: 0, // Urgent -> Critical
|
|
wantType: types.TypeTask,
|
|
wantDepsCount: 0,
|
|
wantHasExtRef: true,
|
|
},
|
|
{
|
|
name: "issue with labels for type inference",
|
|
linearIssue: &linear.Issue{
|
|
ID: "uuid-456",
|
|
Identifier: "TEAM-456",
|
|
Title: "New feature",
|
|
Description: "Add new feature",
|
|
URL: "https://linear.app/team/issue/TEAM-456/new-feature",
|
|
Priority: 2, // High
|
|
State: &linear.State{Type: "unstarted", Name: "Todo"},
|
|
Labels: &linear.Labels{
|
|
Nodes: []linear.Label{{Name: "feature"}, {Name: "priority"}},
|
|
},
|
|
CreatedAt: "2025-01-15T10:00:00Z",
|
|
UpdatedAt: "2025-01-15T10:00:00Z",
|
|
},
|
|
wantTitle: "New feature",
|
|
wantStatus: types.StatusOpen,
|
|
wantPriority: 1, // High -> High
|
|
wantType: types.TypeFeature,
|
|
wantDepsCount: 0,
|
|
wantHasExtRef: true,
|
|
},
|
|
{
|
|
name: "issue with assignee",
|
|
linearIssue: &linear.Issue{
|
|
ID: "uuid-789",
|
|
Identifier: "TEAM-789",
|
|
Title: "Assigned task",
|
|
URL: "https://linear.app/team/issue/TEAM-789/assigned-task",
|
|
Priority: 3, // Medium
|
|
State: &linear.State{Type: "started", Name: "In Progress"},
|
|
Assignee: &linear.User{
|
|
Name: "John Doe",
|
|
Email: "john@example.com",
|
|
},
|
|
CreatedAt: "2025-01-15T10:00:00Z",
|
|
UpdatedAt: "2025-01-15T10:00:00Z",
|
|
},
|
|
wantTitle: "Assigned task",
|
|
wantStatus: types.StatusInProgress,
|
|
wantPriority: 2, // Medium -> Medium
|
|
wantType: types.TypeTask,
|
|
wantAssignee: "john@example.com",
|
|
wantDepsCount: 0,
|
|
wantHasExtRef: true,
|
|
},
|
|
{
|
|
name: "issue with parent creates parent-child dependency",
|
|
linearIssue: &linear.Issue{
|
|
ID: "uuid-child",
|
|
Identifier: "TEAM-200",
|
|
Title: "Child task",
|
|
URL: "https://linear.app/team/issue/TEAM-200/child-task",
|
|
Priority: 3,
|
|
State: &linear.State{Type: "unstarted", Name: "Todo"},
|
|
Parent: &linear.Parent{ID: "uuid-parent", Identifier: "TEAM-100"},
|
|
CreatedAt: "2025-01-15T10:00:00Z",
|
|
UpdatedAt: "2025-01-15T10:00:00Z",
|
|
},
|
|
wantTitle: "Child task",
|
|
wantStatus: types.StatusOpen,
|
|
wantPriority: 2,
|
|
wantType: types.TypeTask,
|
|
wantDepsCount: 1, // Parent-child dependency
|
|
wantHasExtRef: true,
|
|
},
|
|
{
|
|
name: "issue with relations",
|
|
linearIssue: &linear.Issue{
|
|
ID: "uuid-blocker",
|
|
Identifier: "TEAM-300",
|
|
Title: "Blocking issue",
|
|
URL: "https://linear.app/team/issue/TEAM-300/blocking-issue",
|
|
Priority: 2,
|
|
State: &linear.State{Type: "started", Name: "In Progress"},
|
|
Relations: &linear.Relations{
|
|
Nodes: []linear.Relation{
|
|
{
|
|
ID: "rel-1",
|
|
Type: "blocks",
|
|
RelatedIssue: struct {
|
|
ID string `json:"id"`
|
|
Identifier string `json:"identifier"`
|
|
}{ID: "uuid-blocked", Identifier: "TEAM-301"},
|
|
},
|
|
{
|
|
ID: "rel-2",
|
|
Type: "related",
|
|
RelatedIssue: struct {
|
|
ID string `json:"id"`
|
|
Identifier string `json:"identifier"`
|
|
}{ID: "uuid-related", Identifier: "TEAM-302"},
|
|
},
|
|
},
|
|
},
|
|
CreatedAt: "2025-01-15T10:00:00Z",
|
|
UpdatedAt: "2025-01-15T10:00:00Z",
|
|
},
|
|
wantTitle: "Blocking issue",
|
|
wantStatus: types.StatusInProgress,
|
|
wantPriority: 1,
|
|
wantType: types.TypeTask,
|
|
wantDepsCount: 2, // Two relations
|
|
wantHasExtRef: true,
|
|
},
|
|
{
|
|
name: "issue with duplicate relation",
|
|
linearIssue: &linear.Issue{
|
|
ID: "uuid-dup",
|
|
Identifier: "TEAM-350",
|
|
Title: "Duplicate issue",
|
|
URL: "https://linear.app/team/issue/TEAM-350/dup-issue",
|
|
Priority: 3,
|
|
State: &linear.State{Type: "unstarted", Name: "Todo"},
|
|
Relations: &linear.Relations{
|
|
Nodes: []linear.Relation{
|
|
{
|
|
ID: "rel-dup",
|
|
Type: "duplicate",
|
|
RelatedIssue: struct {
|
|
ID string `json:"id"`
|
|
Identifier string `json:"identifier"`
|
|
}{ID: "uuid-canonical", Identifier: "TEAM-351"},
|
|
},
|
|
},
|
|
},
|
|
CreatedAt: "2025-01-15T10:00:00Z",
|
|
UpdatedAt: "2025-01-15T10:00:00Z",
|
|
},
|
|
wantTitle: "Duplicate issue",
|
|
wantStatus: types.StatusOpen,
|
|
wantPriority: 2,
|
|
wantType: types.TypeTask,
|
|
wantDepsCount: 1,
|
|
wantHasExtRef: true,
|
|
},
|
|
{
|
|
name: "closed issue with completedAt",
|
|
linearIssue: &linear.Issue{
|
|
ID: "uuid-closed",
|
|
Identifier: "TEAM-400",
|
|
Title: "Completed task",
|
|
URL: "https://linear.app/team/issue/TEAM-400/completed-task",
|
|
Priority: 3,
|
|
State: &linear.State{Type: "completed", Name: "Done"},
|
|
CreatedAt: "2025-01-10T10:00:00Z",
|
|
UpdatedAt: "2025-01-15T10:00:00Z",
|
|
CompletedAt: "2025-01-15T09:00:00Z",
|
|
},
|
|
wantTitle: "Completed task",
|
|
wantStatus: types.StatusClosed,
|
|
wantPriority: 2,
|
|
wantType: types.TypeTask,
|
|
wantDepsCount: 0,
|
|
wantHasExtRef: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
conversion := linear.IssueToBeads(tt.linearIssue, config)
|
|
issue := conversion.Issue.(*types.Issue)
|
|
|
|
if issue.Title != tt.wantTitle {
|
|
t.Errorf("Title = %s, want %s", issue.Title, tt.wantTitle)
|
|
}
|
|
if issue.Status != tt.wantStatus {
|
|
t.Errorf("Status = %s, want %s", issue.Status, tt.wantStatus)
|
|
}
|
|
if issue.Priority != tt.wantPriority {
|
|
t.Errorf("Priority = %d, want %d", issue.Priority, tt.wantPriority)
|
|
}
|
|
if issue.IssueType != tt.wantType {
|
|
t.Errorf("IssueType = %s, want %s", issue.IssueType, tt.wantType)
|
|
}
|
|
if issue.Assignee != tt.wantAssignee {
|
|
t.Errorf("Assignee = %s, want %s", issue.Assignee, tt.wantAssignee)
|
|
}
|
|
if len(conversion.Dependencies) != tt.wantDepsCount {
|
|
t.Errorf("Dependencies count = %d, want %d",
|
|
len(conversion.Dependencies), tt.wantDepsCount)
|
|
}
|
|
if tt.name == "issue with relations" {
|
|
gotDeps := make(map[string]bool, len(conversion.Dependencies))
|
|
for _, dep := range conversion.Dependencies {
|
|
key := dep.FromLinearID + "->" + dep.ToLinearID + ":" + dep.Type
|
|
gotDeps[key] = true
|
|
}
|
|
if !gotDeps["TEAM-301->TEAM-300:blocks"] {
|
|
t.Error("expected blocks dependency TEAM-301->TEAM-300")
|
|
}
|
|
if !gotDeps["TEAM-300->TEAM-302:related"] {
|
|
t.Error("expected related dependency TEAM-300->TEAM-302")
|
|
}
|
|
}
|
|
if tt.name == "issue with duplicate relation" {
|
|
if len(conversion.Dependencies) != 1 {
|
|
t.Fatalf("expected 1 dependency, got %d", len(conversion.Dependencies))
|
|
}
|
|
dep := conversion.Dependencies[0]
|
|
if dep.Type != "duplicates" {
|
|
t.Errorf("expected dep type duplicates, got %s", dep.Type)
|
|
}
|
|
if dep.FromLinearID != "TEAM-350" || dep.ToLinearID != "TEAM-351" {
|
|
t.Errorf("expected duplicate dependency TEAM-350->TEAM-351, got %s->%s", dep.FromLinearID, dep.ToLinearID)
|
|
}
|
|
}
|
|
if tt.wantHasExtRef && issue.ExternalRef == nil {
|
|
t.Error("ExternalRef should be set")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsLinearExternalRef(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
externalRef string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "valid Linear URL",
|
|
externalRef: "https://linear.app/team/issue/TEAM-123/fix-login-bug",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "Linear URL without slug",
|
|
externalRef: "https://linear.app/team/issue/TEAM-123",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "GitHub issue URL",
|
|
externalRef: "https://github.com/org/repo/issues/123",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "Jira URL",
|
|
externalRef: "https://company.atlassian.net/browse/PROJ-123",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "empty string",
|
|
externalRef: "",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "random URL",
|
|
externalRef: "https://example.com/page",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "Linear URL without /issue/ path",
|
|
externalRef: "https://linear.app/team/TEAM-123",
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := linear.IsLinearExternalRef(tt.externalRef)
|
|
if got != tt.want {
|
|
t.Errorf("IsLinearExternalRef(%q) = %v, want %v",
|
|
tt.externalRef, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractLinearIdentifier(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
want string
|
|
}{
|
|
{
|
|
name: "standard Linear URL",
|
|
url: "https://linear.app/team/issue/TEAM-123/fix-login-bug",
|
|
want: "TEAM-123",
|
|
},
|
|
{
|
|
name: "Linear URL without slug",
|
|
url: "https://linear.app/team/issue/TEAM-456",
|
|
want: "TEAM-456",
|
|
},
|
|
{
|
|
name: "Linear URL with long identifier",
|
|
url: "https://linear.app/myteam/issue/PROJECT-9999/very-long-title-slug",
|
|
want: "PROJECT-9999",
|
|
},
|
|
{
|
|
name: "URL without issue path",
|
|
url: "https://linear.app/team/TEAM-123",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "empty URL",
|
|
url: "",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "GitHub URL",
|
|
url: "https://github.com/org/repo/issues/123",
|
|
want: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := linear.ExtractLinearIdentifier(tt.url)
|
|
if got != tt.want {
|
|
t.Errorf("ExtractLinearIdentifier(%q) = %q, want %q",
|
|
tt.url, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMaskAPIKey(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
key string
|
|
want string
|
|
}{
|
|
{
|
|
name: "long key",
|
|
key: "lin_api_12345678901234567890",
|
|
want: "lin_...7890",
|
|
},
|
|
{
|
|
name: "short key",
|
|
key: "short",
|
|
want: "****",
|
|
},
|
|
{
|
|
name: "exactly 8 chars",
|
|
key: "12345678",
|
|
want: "****",
|
|
},
|
|
{
|
|
name: "9 chars",
|
|
key: "123456789",
|
|
want: "1234...6789",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := maskAPIKey(tt.key)
|
|
if got != tt.want {
|
|
t.Errorf("maskAPIKey(%q) = %q, want %q", tt.key, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseBeadsStatus(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
wantStatus types.Status
|
|
}{
|
|
{"open", types.StatusOpen},
|
|
{"OPEN", types.StatusOpen},
|
|
{"in_progress", types.StatusInProgress},
|
|
{"in-progress", types.StatusInProgress},
|
|
{"inprogress", types.StatusInProgress},
|
|
{"blocked", types.StatusBlocked},
|
|
{"closed", types.StatusClosed},
|
|
{"CLOSED", types.StatusClosed},
|
|
{"unknown", types.StatusOpen},
|
|
{"", types.StatusOpen},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
got := linear.ParseBeadsStatus(tt.input)
|
|
if got != tt.wantStatus {
|
|
t.Errorf("ParseBeadsStatus(%q) = %s, want %s",
|
|
tt.input, got, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseIssueType(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
wantType types.IssueType
|
|
}{
|
|
{"bug", types.TypeBug},
|
|
{"BUG", types.TypeBug},
|
|
{"feature", types.TypeFeature},
|
|
{"task", types.TypeTask},
|
|
{"epic", types.TypeEpic},
|
|
{"chore", types.TypeChore},
|
|
{"unknown", types.TypeTask},
|
|
{"", types.TypeTask},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
got := linear.ParseIssueType(tt.input)
|
|
if got != tt.wantType {
|
|
t.Errorf("ParseIssueType(%q) = %s, want %s",
|
|
tt.input, got, tt.wantType)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDefaultLinearMappingConfig(t *testing.T) {
|
|
config := linear.DefaultMappingConfig()
|
|
|
|
// Test priority map has expected entries
|
|
expectedPriorityMap := map[string]int{
|
|
"0": 4, "1": 0, "2": 1, "3": 2, "4": 3,
|
|
}
|
|
for k, v := range expectedPriorityMap {
|
|
if got, ok := config.PriorityMap[k]; !ok || got != v {
|
|
t.Errorf("PriorityMap[%s] = %d, want %d", k, got, v)
|
|
}
|
|
}
|
|
|
|
// Test state map has expected entries
|
|
expectedStateMap := map[string]string{
|
|
"backlog": "open", "unstarted": "open", "started": "in_progress",
|
|
"completed": "closed", "canceled": "closed",
|
|
}
|
|
for k, v := range expectedStateMap {
|
|
if got, ok := config.StateMap[k]; !ok || got != v {
|
|
t.Errorf("StateMap[%s] = %s, want %s", k, got, v)
|
|
}
|
|
}
|
|
|
|
// Test label type map has expected entries
|
|
expectedLabelMap := map[string]string{
|
|
"bug": "bug", "defect": "bug", "feature": "feature",
|
|
"enhancement": "feature", "epic": "epic", "chore": "chore",
|
|
"maintenance": "chore", "task": "task",
|
|
}
|
|
for k, v := range expectedLabelMap {
|
|
if got, ok := config.LabelTypeMap[k]; !ok || got != v {
|
|
t.Errorf("LabelTypeMap[%s] = %s, want %s", k, got, v)
|
|
}
|
|
}
|
|
|
|
// Test relation map has expected entries
|
|
expectedRelationMap := map[string]string{
|
|
"blocks": "blocks", "blockedBy": "blocks",
|
|
"duplicate": "duplicates", "related": "related",
|
|
}
|
|
for k, v := range expectedRelationMap {
|
|
if got, ok := config.RelationMap[k]; !ok || got != v {
|
|
t.Errorf("RelationMap[%s] = %s, want %s", k, got, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
return f(r)
|
|
}
|
|
|
|
func TestFetchIssueByIdentifierSendsNumericFilter(t *testing.T) {
|
|
client := linear.NewClient("test-api-key", "team-123")
|
|
|
|
origTransport := http.DefaultTransport
|
|
http.DefaultTransport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read request body: %w", err)
|
|
}
|
|
_ = r.Body.Close()
|
|
|
|
var gqlReq linear.GraphQLRequest
|
|
if err := json.Unmarshal(body, &gqlReq); err != nil {
|
|
return nil, fmt.Errorf("decode request body: %w", err)
|
|
}
|
|
|
|
filter, ok := gqlReq.Variables["filter"].(map[string]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("missing filter in variables")
|
|
}
|
|
numberFilter, ok := filter["number"].(map[string]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("missing number filter in variables")
|
|
}
|
|
eq, ok := numberFilter["eq"].(float64)
|
|
if !ok {
|
|
return nil, fmt.Errorf("number.eq is not numeric (got %T)", numberFilter["eq"])
|
|
}
|
|
if eq != 123 {
|
|
return nil, fmt.Errorf("expected number.eq=123, got %v", eq)
|
|
}
|
|
|
|
resp := struct {
|
|
Data json.RawMessage `json:"data"`
|
|
Errors []interface{} `json:"errors,omitempty"`
|
|
}{
|
|
Data: json.RawMessage(`{"issues":{"nodes":[]}}`),
|
|
}
|
|
respBytes, err := json.Marshal(resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode response: %w", err)
|
|
}
|
|
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(bytes.NewReader(respBytes)),
|
|
Request: r,
|
|
}, nil
|
|
})
|
|
t.Cleanup(func() { http.DefaultTransport = origTransport })
|
|
|
|
_, err := client.FetchIssueByIdentifier(context.Background(), "TEAM-123")
|
|
if err != nil {
|
|
t.Fatalf("FetchIssueByIdentifier failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDoPushToLinearPreferLocalForcesUpdate(t *testing.T) {
|
|
testStore, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
if err := testStore.SetConfig(ctx, "linear.api_key", "test-api-key"); err != nil {
|
|
t.Fatalf("SetConfig linear.api_key failed: %v", err)
|
|
}
|
|
if err := testStore.SetConfig(ctx, "linear.team_id", "12345678-1234-1234-1234-123456789abc"); err != nil {
|
|
t.Fatalf("SetConfig linear.team_id failed: %v", err)
|
|
}
|
|
|
|
localUpdated := time.Now().Add(-2 * time.Hour)
|
|
issue := &types.Issue{
|
|
Title: "Local Issue",
|
|
Description: "Local description",
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
Status: types.StatusInProgress,
|
|
CreatedAt: localUpdated,
|
|
UpdatedAt: localUpdated,
|
|
}
|
|
externalRef := "https://linear.app/team/issue/TEAM-123/local-issue"
|
|
issue.ExternalRef = &externalRef
|
|
if err := testStore.CreateIssue(ctx, issue, "test-actor"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
|
|
remoteUpdated := time.Now().Add(-1 * time.Hour)
|
|
remoteUpdatedStr := remoteUpdated.UTC().Format(time.RFC3339)
|
|
|
|
updatedCalled := false
|
|
origTransport := http.DefaultTransport
|
|
http.DefaultTransport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read request body: %w", err)
|
|
}
|
|
_ = r.Body.Close()
|
|
|
|
var gqlReq linear.GraphQLRequest
|
|
if err := json.Unmarshal(body, &gqlReq); err != nil {
|
|
return nil, fmt.Errorf("decode request body: %w", err)
|
|
}
|
|
|
|
var resp struct {
|
|
Data json.RawMessage `json:"data"`
|
|
Errors []interface{} `json:"errors,omitempty"`
|
|
}
|
|
switch {
|
|
case strings.Contains(gqlReq.Query, "TeamStates"):
|
|
resp.Data = json.RawMessage(`{
|
|
"team": {
|
|
"id": "team-123",
|
|
"states": {
|
|
"nodes": [
|
|
{"id": "state-started", "name": "In Progress", "type": "started"}
|
|
]
|
|
}
|
|
}
|
|
}`)
|
|
case strings.Contains(gqlReq.Query, "IssueByIdentifier"):
|
|
resp.Data = json.RawMessage(fmt.Sprintf(`{
|
|
"issues": {
|
|
"nodes": [
|
|
{
|
|
"id": "uuid-123",
|
|
"identifier": "TEAM-123",
|
|
"title": "Remote Issue",
|
|
"description": "Remote description",
|
|
"url": "https://linear.app/team/issue/TEAM-123/remote-issue",
|
|
"priority": 2,
|
|
"state": {"id": "state-started", "name": "In Progress", "type": "started"},
|
|
"labels": {"nodes": []},
|
|
"createdAt": "2025-01-01T00:00:00Z",
|
|
"updatedAt": "%s"
|
|
}
|
|
]
|
|
}
|
|
}`, remoteUpdatedStr))
|
|
case strings.Contains(gqlReq.Query, "UpdateIssue"):
|
|
updatedCalled = true
|
|
resp.Data = json.RawMessage(`{
|
|
"issueUpdate": {
|
|
"success": true,
|
|
"issue": {
|
|
"id": "uuid-123",
|
|
"identifier": "TEAM-123",
|
|
"title": "Updated Title",
|
|
"description": "Updated description",
|
|
"url": "https://linear.app/team/issue/TEAM-123/remote-issue",
|
|
"priority": 2,
|
|
"state": {"id": "state-started", "name": "In Progress", "type": "started"},
|
|
"updatedAt": "2025-01-02T00:00:00Z"
|
|
}
|
|
}
|
|
}`)
|
|
default:
|
|
return nil, fmt.Errorf("unexpected query: %s", gqlReq.Query)
|
|
}
|
|
|
|
respBytes, err := json.Marshal(resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode response: %w", err)
|
|
}
|
|
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(bytes.NewReader(respBytes)),
|
|
Request: r,
|
|
}, nil
|
|
})
|
|
t.Cleanup(func() { http.DefaultTransport = origTransport })
|
|
|
|
origStore := store
|
|
origActor := actor
|
|
store = testStore
|
|
actor = "test-actor"
|
|
t.Cleanup(func() {
|
|
store = origStore
|
|
actor = origActor
|
|
})
|
|
|
|
forceUpdateIDs := map[string]bool{issue.ID: true}
|
|
stats, err := doPushToLinear(ctx, false, false, true, forceUpdateIDs, nil)
|
|
if err != nil {
|
|
t.Fatalf("doPushToLinear failed: %v", err)
|
|
}
|
|
if !updatedCalled {
|
|
t.Fatal("expected UpdateIssue to be called when force-update is enabled")
|
|
}
|
|
if stats.Updated != 1 {
|
|
t.Fatalf("expected Updated=1, got %d", stats.Updated)
|
|
}
|
|
if stats.Skipped != 0 {
|
|
t.Fatalf("expected Skipped=0, got %d", stats.Skipped)
|
|
}
|
|
}
|
|
|
|
func TestLinearClientFetchIssues(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
// Create a mock GraphQL server
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Verify headers
|
|
if r.Header.Get("Content-Type") != "application/json" {
|
|
t.Errorf("expected Content-Type: application/json, got %s", r.Header.Get("Content-Type"))
|
|
}
|
|
if r.Header.Get("Authorization") == "" {
|
|
t.Error("expected Authorization header to be set")
|
|
}
|
|
|
|
// Return mock response
|
|
response := struct {
|
|
Data json.RawMessage `json:"data"`
|
|
Errors []interface{} `json:"errors,omitempty"`
|
|
}{
|
|
Data: json.RawMessage(`{
|
|
"issues": {
|
|
"nodes": [
|
|
{
|
|
"id": "uuid-1",
|
|
"identifier": "TEAM-1",
|
|
"title": "Test Issue 1",
|
|
"description": "Description 1",
|
|
"url": "https://linear.app/team/issue/TEAM-1/test-issue",
|
|
"priority": 2,
|
|
"state": {
|
|
"id": "state-1",
|
|
"name": "In Progress",
|
|
"type": "started"
|
|
},
|
|
"labels": {
|
|
"nodes": [
|
|
{"id": "label-1", "name": "bug"}
|
|
]
|
|
},
|
|
"createdAt": "2025-01-15T10:00:00Z",
|
|
"updatedAt": "2025-01-16T10:00:00Z"
|
|
},
|
|
{
|
|
"id": "uuid-2",
|
|
"identifier": "TEAM-2",
|
|
"title": "Test Issue 2",
|
|
"description": "Description 2",
|
|
"url": "https://linear.app/team/issue/TEAM-2/test-issue-2",
|
|
"priority": 3,
|
|
"state": {
|
|
"id": "state-2",
|
|
"name": "Todo",
|
|
"type": "unstarted"
|
|
},
|
|
"createdAt": "2025-01-15T10:00:00Z",
|
|
"updatedAt": "2025-01-15T10:00:00Z"
|
|
}
|
|
],
|
|
"pageInfo": {
|
|
"hasNextPage": false,
|
|
"endCursor": ""
|
|
}
|
|
}
|
|
}`),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
t.Fatalf("failed to encode response: %v", err)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Create client with mock server
|
|
client := linear.NewClient("test-api-key", "test-team-id").WithEndpoint(server.URL)
|
|
|
|
ctx := context.Background()
|
|
issues, err := client.FetchIssues(ctx, "all")
|
|
if err != nil {
|
|
t.Fatalf("FetchIssues failed: %v", err)
|
|
}
|
|
|
|
// Verify response
|
|
if len(issues) != 2 {
|
|
t.Errorf("expected 2 issues, got %d", len(issues))
|
|
}
|
|
|
|
// Check first issue
|
|
issue1 := issues[0]
|
|
if issue1.Identifier != "TEAM-1" {
|
|
t.Errorf("expected identifier TEAM-1, got %s", issue1.Identifier)
|
|
}
|
|
if issue1.Title != "Test Issue 1" {
|
|
t.Errorf("expected title 'Test Issue 1', got %s", issue1.Title)
|
|
}
|
|
if issue1.State.Type != "started" {
|
|
t.Errorf("expected state type 'started', got %s", issue1.State.Type)
|
|
}
|
|
}
|
|
|
|
func TestLinearClientCreateIssue(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
// Create a mock GraphQL server for create mutation
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
response := struct {
|
|
Data json.RawMessage `json:"data"`
|
|
Errors []interface{} `json:"errors,omitempty"`
|
|
}{
|
|
Data: json.RawMessage(`{
|
|
"issueCreate": {
|
|
"success": true,
|
|
"issue": {
|
|
"id": "uuid-new",
|
|
"identifier": "TEAM-999",
|
|
"title": "New Test Issue",
|
|
"description": "Created via API",
|
|
"url": "https://linear.app/team/issue/TEAM-999/new-test-issue",
|
|
"priority": 2,
|
|
"state": {
|
|
"id": "state-1",
|
|
"name": "Todo",
|
|
"type": "unstarted"
|
|
},
|
|
"createdAt": "2025-01-17T10:00:00Z",
|
|
"updatedAt": "2025-01-17T10:00:00Z"
|
|
}
|
|
}
|
|
}`),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
t.Fatalf("failed to encode response: %v", err)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := linear.NewClient("test-api-key", "test-team-id").WithEndpoint(server.URL)
|
|
ctx := context.Background()
|
|
|
|
issue, err := client.CreateIssue(ctx, "New Test Issue", "Created via API", 2, "", nil)
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
|
|
if issue.Identifier != "TEAM-999" {
|
|
t.Errorf("expected identifier TEAM-999, got %s", issue.Identifier)
|
|
}
|
|
}
|
|
|
|
func TestLinearClientUpdateIssue(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
// Create a mock GraphQL server for update mutation
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
response := struct {
|
|
Data json.RawMessage `json:"data"`
|
|
Errors []interface{} `json:"errors,omitempty"`
|
|
}{
|
|
Data: json.RawMessage(`{
|
|
"issueUpdate": {
|
|
"success": true,
|
|
"issue": {
|
|
"id": "uuid-existing",
|
|
"identifier": "TEAM-100",
|
|
"title": "Updated Title",
|
|
"description": "Updated description",
|
|
"url": "https://linear.app/team/issue/TEAM-100/updated-title",
|
|
"priority": 1,
|
|
"state": {
|
|
"id": "state-done",
|
|
"name": "Done",
|
|
"type": "completed"
|
|
},
|
|
"updatedAt": "2025-01-17T12:00:00Z"
|
|
}
|
|
}
|
|
}`),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
t.Fatalf("failed to encode response: %v", err)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := linear.NewClient("test-api-key", "test-team-id").WithEndpoint(server.URL)
|
|
ctx := context.Background()
|
|
|
|
updates := map[string]interface{}{
|
|
"title": "Updated Title",
|
|
"description": "Updated description",
|
|
}
|
|
issue, err := client.UpdateIssue(ctx, "uuid-existing", updates)
|
|
if err != nil {
|
|
t.Fatalf("UpdateIssue failed: %v", err)
|
|
}
|
|
|
|
if issue.Title != "Updated Title" {
|
|
t.Errorf("expected title 'Updated Title', got %s", issue.Title)
|
|
}
|
|
if issue.State.Type != "completed" {
|
|
t.Errorf("expected state type 'completed', got %s", issue.State.Type)
|
|
}
|
|
}
|
|
|
|
func TestLinearClientGetTeamStates(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
// Create a mock GraphQL server for team states query
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
response := struct {
|
|
Data json.RawMessage `json:"data"`
|
|
Errors []interface{} `json:"errors,omitempty"`
|
|
}{
|
|
Data: json.RawMessage(`{
|
|
"team": {
|
|
"id": "team-123",
|
|
"states": {
|
|
"nodes": [
|
|
{"id": "state-1", "name": "Backlog", "type": "backlog"},
|
|
{"id": "state-2", "name": "Todo", "type": "unstarted"},
|
|
{"id": "state-3", "name": "In Progress", "type": "started"},
|
|
{"id": "state-4", "name": "Done", "type": "completed"},
|
|
{"id": "state-5", "name": "Cancelled", "type": "canceled"}
|
|
]
|
|
}
|
|
}
|
|
}`),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
t.Fatalf("failed to encode response: %v", err)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := linear.NewClient("test-api-key", "test-team-id").WithEndpoint(server.URL)
|
|
ctx := context.Background()
|
|
|
|
states, err := client.GetTeamStates(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetTeamStates failed: %v", err)
|
|
}
|
|
|
|
// Verify response
|
|
if len(states) != 5 {
|
|
t.Errorf("expected 5 states, got %d", len(states))
|
|
}
|
|
|
|
// Verify state types
|
|
expectedTypes := []string{"backlog", "unstarted", "started", "completed", "canceled"}
|
|
for i, expected := range expectedTypes {
|
|
if states[i].Type != expected {
|
|
t.Errorf("state %d: expected type %s, got %s",
|
|
i, expected, states[i].Type)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLinearClientRateLimitHandling(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
// Create a mock server that returns 429 then succeeds
|
|
attempts := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
if attempts == 1 {
|
|
// First attempt: rate limited
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
// Subsequent attempts: success
|
|
response := struct {
|
|
Data json.RawMessage `json:"data"`
|
|
Errors []interface{} `json:"errors,omitempty"`
|
|
}{
|
|
Data: json.RawMessage(`{"issues": {"nodes": [], "pageInfo": {"hasNextPage": false}}}`),
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
t.Fatalf("failed to encode response: %v", err)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Verify that rate limiting was simulated
|
|
httpClient := &http.Client{Timeout: 10 * time.Second}
|
|
ctx := context.Background()
|
|
|
|
// First request: expect 429
|
|
req1, _ := http.NewRequestWithContext(ctx, "POST", server.URL, nil)
|
|
resp1, err := httpClient.Do(req1)
|
|
if err != nil {
|
|
t.Fatalf("first request failed: %v", err)
|
|
}
|
|
resp1.Body.Close()
|
|
if resp1.StatusCode != http.StatusTooManyRequests {
|
|
t.Errorf("expected 429, got %d", resp1.StatusCode)
|
|
}
|
|
|
|
// Second request: expect success
|
|
req2, _ := http.NewRequestWithContext(ctx, "POST", server.URL, nil)
|
|
resp2, err := httpClient.Do(req2)
|
|
if err != nil {
|
|
t.Fatalf("second request failed: %v", err)
|
|
}
|
|
resp2.Body.Close()
|
|
if resp2.StatusCode != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", resp2.StatusCode)
|
|
}
|
|
|
|
if attempts != 2 {
|
|
t.Errorf("expected 2 attempts, got %d", attempts)
|
|
}
|
|
}
|
|
|
|
func TestLinearClientGraphQLError(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
// Create a mock server that returns a GraphQL error
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
response := struct {
|
|
Data json.RawMessage `json:"data,omitempty"`
|
|
Errors []linear.GraphQLError `json:"errors,omitempty"`
|
|
}{
|
|
Errors: []linear.GraphQLError{
|
|
{
|
|
Message: "Issue not found",
|
|
Path: []string{"issues"},
|
|
},
|
|
},
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
t.Fatalf("failed to encode response: %v", err)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := linear.NewClient("test-api-key", "test-team-id").WithEndpoint(server.URL)
|
|
ctx := context.Background()
|
|
|
|
_, err := client.FetchIssues(ctx, "all")
|
|
if err == nil {
|
|
t.Error("expected error for GraphQL error response")
|
|
}
|
|
if !strings.Contains(err.Error(), "Issue not found") {
|
|
t.Errorf("expected error to contain 'Issue not found', got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLinearStateCacheFindStateForBeadsStatus(t *testing.T) {
|
|
cache := &linear.StateCache{
|
|
States: []linear.State{
|
|
{ID: "state-1", Name: "Backlog", Type: "backlog"},
|
|
{ID: "state-2", Name: "Todo", Type: "unstarted"},
|
|
{ID: "state-3", Name: "In Progress", Type: "started"},
|
|
{ID: "state-4", Name: "Done", Type: "completed"},
|
|
{ID: "state-5", Name: "Cancelled", Type: "canceled"},
|
|
},
|
|
StatesByID: make(map[string]linear.State),
|
|
OpenStateID: "state-2",
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
status types.Status
|
|
wantStateID string
|
|
}{
|
|
{
|
|
name: "open status finds unstarted state",
|
|
status: types.StatusOpen,
|
|
wantStateID: "state-2",
|
|
},
|
|
{
|
|
name: "in_progress status finds started state",
|
|
status: types.StatusInProgress,
|
|
wantStateID: "state-3",
|
|
},
|
|
{
|
|
name: "blocked status finds started state (no blocked in Linear)",
|
|
status: types.StatusBlocked,
|
|
wantStateID: "state-3",
|
|
},
|
|
{
|
|
name: "closed status finds completed state",
|
|
status: types.StatusClosed,
|
|
wantStateID: "state-4",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := cache.FindStateForBeadsStatus(tt.status)
|
|
if got != tt.wantStateID {
|
|
t.Errorf("FindStateForBeadsStatus(%s) = %s, want %s",
|
|
tt.status, got, tt.wantStateID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLinearStateCacheEmpty(t *testing.T) {
|
|
cache := &linear.StateCache{
|
|
States: []linear.State{},
|
|
StatesByID: make(map[string]linear.State),
|
|
}
|
|
|
|
// Should return empty string when no states available
|
|
got := cache.FindStateForBeadsStatus(types.StatusOpen)
|
|
if got != "" {
|
|
t.Errorf("expected empty string for empty cache, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildLinearToLocalUpdates(t *testing.T) {
|
|
config := linear.DefaultMappingConfig()
|
|
|
|
li := &linear.Issue{
|
|
ID: "uuid-123",
|
|
Identifier: "TEAM-123",
|
|
Title: "Updated Title",
|
|
Description: "Updated Description",
|
|
Priority: 2, // High
|
|
State: &linear.State{Type: "started", Name: "In Progress"},
|
|
Assignee: &linear.User{Email: "test@example.com", Name: "Test User"},
|
|
Labels: &linear.Labels{
|
|
Nodes: []linear.Label{
|
|
{Name: "bug"},
|
|
{Name: "priority"},
|
|
},
|
|
},
|
|
UpdatedAt: "2025-01-17T10:00:00Z",
|
|
CompletedAt: "",
|
|
}
|
|
|
|
updates := linear.BuildLinearToLocalUpdates(li, config)
|
|
|
|
// Verify all expected fields are present
|
|
if updates["title"] != "Updated Title" {
|
|
t.Errorf("expected title 'Updated Title', got %v", updates["title"])
|
|
}
|
|
if updates["description"] != "Updated Description" {
|
|
t.Errorf("expected description 'Updated Description', got %v", updates["description"])
|
|
}
|
|
if updates["priority"] != 1 { // High -> High
|
|
t.Errorf("expected priority 1, got %v", updates["priority"])
|
|
}
|
|
if updates["status"] != "in_progress" {
|
|
t.Errorf("expected status 'in_progress', got %v", updates["status"])
|
|
}
|
|
if updates["assignee"] != "test@example.com" {
|
|
t.Errorf("expected assignee 'test@example.com', got %v", updates["assignee"])
|
|
}
|
|
|
|
// Check labels
|
|
labels, ok := updates["labels"].([]string)
|
|
if !ok {
|
|
t.Fatalf("expected labels to be []string, got %T", updates["labels"])
|
|
}
|
|
if len(labels) != 2 {
|
|
t.Errorf("expected 2 labels, got %d", len(labels))
|
|
}
|
|
}
|
|
|
|
func TestBuildLinearToLocalUpdatesNoAssignee(t *testing.T) {
|
|
config := linear.DefaultMappingConfig()
|
|
|
|
li := &linear.Issue{
|
|
ID: "uuid-123",
|
|
Identifier: "TEAM-123",
|
|
Title: "Unassigned Issue",
|
|
Description: "No assignee",
|
|
Priority: 3,
|
|
State: &linear.State{Type: "unstarted", Name: "Todo"},
|
|
Assignee: nil,
|
|
UpdatedAt: "2025-01-17T10:00:00Z",
|
|
}
|
|
|
|
updates := linear.BuildLinearToLocalUpdates(li, config)
|
|
|
|
// Assignee should be empty string when nil
|
|
if updates["assignee"] != "" {
|
|
t.Errorf("expected empty assignee, got %v", updates["assignee"])
|
|
}
|
|
}
|
|
|
|
func TestBuildLinearToLocalUpdatesWithClosedAt(t *testing.T) {
|
|
config := linear.DefaultMappingConfig()
|
|
|
|
li := &linear.Issue{
|
|
ID: "uuid-123",
|
|
Identifier: "TEAM-123",
|
|
Title: "Completed Issue",
|
|
Description: "Done",
|
|
Priority: 3,
|
|
State: &linear.State{Type: "completed", Name: "Done"},
|
|
UpdatedAt: "2025-01-17T10:00:00Z",
|
|
CompletedAt: "2025-01-17T09:00:00Z",
|
|
}
|
|
|
|
updates := linear.BuildLinearToLocalUpdates(li, config)
|
|
|
|
// Check closed_at is set
|
|
closedAt, ok := updates["closed_at"].(time.Time)
|
|
if !ok {
|
|
t.Fatalf("expected closed_at to be time.Time, got %T", updates["closed_at"])
|
|
}
|
|
if closedAt.IsZero() {
|
|
t.Error("closed_at should not be zero")
|
|
}
|
|
}
|
|
|
|
func TestLinearClientFetchTeams(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
// Create a mock GraphQL server for teams query
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
response := struct {
|
|
Data json.RawMessage `json:"data"`
|
|
Errors []interface{} `json:"errors,omitempty"`
|
|
}{
|
|
Data: json.RawMessage(`{
|
|
"teams": {
|
|
"nodes": [
|
|
{
|
|
"id": "12345678-1234-1234-1234-123456789abc",
|
|
"name": "Engineering",
|
|
"key": "ENG"
|
|
},
|
|
{
|
|
"id": "87654321-4321-4321-4321-cba987654321",
|
|
"name": "Product",
|
|
"key": "PROD"
|
|
}
|
|
]
|
|
}
|
|
}`),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
t.Fatalf("failed to encode response: %v", err)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Create client with empty team ID (not needed for fetching teams)
|
|
client := linear.NewClient("test-api-key", "").WithEndpoint(server.URL)
|
|
ctx := context.Background()
|
|
|
|
teams, err := client.FetchTeams(ctx)
|
|
if err != nil {
|
|
t.Fatalf("FetchTeams failed: %v", err)
|
|
}
|
|
|
|
if len(teams) != 2 {
|
|
t.Errorf("expected 2 teams, got %d", len(teams))
|
|
}
|
|
|
|
// Check first team
|
|
if teams[0].ID != "12345678-1234-1234-1234-123456789abc" {
|
|
t.Errorf("expected team ID '12345678-1234-1234-1234-123456789abc', got %s", teams[0].ID)
|
|
}
|
|
if teams[0].Name != "Engineering" {
|
|
t.Errorf("expected team name 'Engineering', got %s", teams[0].Name)
|
|
}
|
|
if teams[0].Key != "ENG" {
|
|
t.Errorf("expected team key 'ENG', got %s", teams[0].Key)
|
|
}
|
|
|
|
// Check second team
|
|
if teams[1].Key != "PROD" {
|
|
t.Errorf("expected team key 'PROD', got %s", teams[1].Key)
|
|
}
|
|
}
|
|
|
|
func TestIsValidUUID(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "valid UUID with hyphens",
|
|
input: "12345678-1234-1234-1234-123456789abc",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "valid UUID without hyphens",
|
|
input: "12345678123412341234123456789abc",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "valid UUID uppercase",
|
|
input: "12345678-1234-1234-1234-123456789ABC",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "valid UUID mixed case",
|
|
input: "12345678-1234-1234-1234-123456789AbC",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "invalid - too short",
|
|
input: "12345678-1234-1234-1234",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "invalid - too long",
|
|
input: "12345678-1234-1234-1234-123456789abcdef",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "invalid - contains non-hex",
|
|
input: "12345678-1234-1234-1234-123456789xyz",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "invalid - empty string",
|
|
input: "",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "invalid - team name instead of UUID",
|
|
input: "my-team-name",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "invalid - just numbers",
|
|
input: "12345678",
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := isValidUUID(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("isValidUUID(%q) = %v, want %v", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|