diff --git a/internal/linear/client_test.go b/internal/linear/client_test.go index 5d13af33..645db66d 100644 --- a/internal/linear/client_test.go +++ b/internal/linear/client_test.go @@ -1,6 +1,10 @@ package linear -import "testing" +import ( + "net/http" + "testing" + "time" +) func TestCanonicalizeLinearExternalRef(t *testing.T) { tests := []struct { @@ -39,3 +43,130 @@ func TestCanonicalizeLinearExternalRef(t *testing.T) { } } } + +func TestNewClient(t *testing.T) { + client := NewClient("test-api-key", "test-team-id") + + if client.APIKey != "test-api-key" { + t.Errorf("APIKey = %q, want %q", client.APIKey, "test-api-key") + } + if client.TeamID != "test-team-id" { + t.Errorf("TeamID = %q, want %q", client.TeamID, "test-team-id") + } + if client.Endpoint != DefaultAPIEndpoint { + t.Errorf("Endpoint = %q, want %q", client.Endpoint, DefaultAPIEndpoint) + } + if client.HTTPClient == nil { + t.Error("HTTPClient should not be nil") + } +} + +func TestWithEndpoint(t *testing.T) { + client := NewClient("key", "team") + customEndpoint := "https://custom.linear.app/graphql" + + newClient := client.WithEndpoint(customEndpoint) + + if newClient.Endpoint != customEndpoint { + t.Errorf("Endpoint = %q, want %q", newClient.Endpoint, customEndpoint) + } + // Original should be unchanged + if client.Endpoint != DefaultAPIEndpoint { + t.Errorf("Original endpoint changed: %q", client.Endpoint) + } + // Other fields preserved + if newClient.APIKey != "key" { + t.Errorf("APIKey not preserved: %q", newClient.APIKey) + } +} + +func TestWithHTTPClient(t *testing.T) { + client := NewClient("key", "team") + customHTTPClient := &http.Client{Timeout: 60 * time.Second} + + newClient := client.WithHTTPClient(customHTTPClient) + + if newClient.HTTPClient != customHTTPClient { + t.Error("HTTPClient not set correctly") + } + // Other fields preserved + if newClient.APIKey != "key" { + t.Errorf("APIKey not preserved: %q", newClient.APIKey) + } + if newClient.Endpoint != DefaultAPIEndpoint { + t.Errorf("Endpoint not preserved: %q", newClient.Endpoint) + } +} + +func TestExtractLinearIdentifier(t *testing.T) { + tests := []struct { + name string + externalRef string + want string + }{ + { + name: "standard URL", + externalRef: "https://linear.app/team/issue/PROJ-123", + want: "PROJ-123", + }, + { + name: "URL with slug", + externalRef: "https://linear.app/team/issue/PROJ-456/some-title-here", + want: "PROJ-456", + }, + { + name: "URL with trailing slash", + externalRef: "https://linear.app/team/issue/ABC-789/", + want: "ABC-789", + }, + { + name: "non-linear URL", + externalRef: "https://jira.example.com/browse/PROJ-123", + want: "", + }, + { + name: "empty string", + externalRef: "", + want: "", + }, + { + name: "malformed URL", + externalRef: "not-a-url", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractLinearIdentifier(tt.externalRef) + if got != tt.want { + t.Errorf("ExtractLinearIdentifier(%q) = %q, want %q", tt.externalRef, got, tt.want) + } + }) + } +} + +func TestIsLinearExternalRef(t *testing.T) { + tests := []struct { + ref string + want bool + }{ + {"https://linear.app/team/issue/PROJ-123", true}, + {"https://linear.app/team/issue/PROJ-123/slug", true}, + {"https://jira.example.com/browse/PROJ-123", false}, + {"https://github.com/org/repo/issues/123", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.ref, func(t *testing.T) { + got := IsLinearExternalRef(tt.ref) + if got != tt.want { + t.Errorf("IsLinearExternalRef(%q) = %v, want %v", tt.ref, got, tt.want) + } + }) + } +} + +// Note: BuildStateCache and FindStateForBeadsStatus require API calls +// and would need mocking to test. Skipping unit tests for those. diff --git a/internal/linear/mapping_test.go b/internal/linear/mapping_test.go index eb967b4a..c0c21d5b 100644 --- a/internal/linear/mapping_test.go +++ b/internal/linear/mapping_test.go @@ -142,3 +142,460 @@ func TestNormalizeIssueForLinearHashCanonicalizesExternalRef(t *testing.T) { func hasPrefix(s, prefix string) bool { return len(s) >= len(prefix) && s[:len(prefix)] == prefix } + +func TestDefaultMappingConfig(t *testing.T) { + config := DefaultMappingConfig() + + // Check priority mappings + if config.PriorityMap["0"] != 4 { + t.Errorf("PriorityMap[0] = %d, want 4", config.PriorityMap["0"]) + } + if config.PriorityMap["1"] != 0 { + t.Errorf("PriorityMap[1] = %d, want 0", config.PriorityMap["1"]) + } + + // Check state mappings + if config.StateMap["backlog"] != "open" { + t.Errorf("StateMap[backlog] = %s, want open", config.StateMap["backlog"]) + } + if config.StateMap["started"] != "in_progress" { + t.Errorf("StateMap[started] = %s, want in_progress", config.StateMap["started"]) + } + if config.StateMap["completed"] != "closed" { + t.Errorf("StateMap[completed] = %s, want closed", config.StateMap["completed"]) + } + + // Check label type mappings + if config.LabelTypeMap["bug"] != "bug" { + t.Errorf("LabelTypeMap[bug] = %s, want bug", config.LabelTypeMap["bug"]) + } + if config.LabelTypeMap["feature"] != "feature" { + t.Errorf("LabelTypeMap[feature] = %s, want feature", config.LabelTypeMap["feature"]) + } + + // Check relation mappings + if config.RelationMap["blocks"] != "blocks" { + t.Errorf("RelationMap[blocks] = %s, want blocks", config.RelationMap["blocks"]) + } +} + +func TestPriorityToBeads(t *testing.T) { + config := DefaultMappingConfig() + + tests := []struct { + linearPriority int + want int + }{ + {0, 4}, // No priority -> Backlog + {1, 0}, // Urgent -> Critical + {2, 1}, // High -> High + {3, 2}, // Medium -> Medium + {4, 3}, // Low -> Low + {5, 2}, // Unknown -> Medium (default) + } + + for _, tt := range tests { + got := PriorityToBeads(tt.linearPriority, config) + if got != tt.want { + t.Errorf("PriorityToBeads(%d) = %d, want %d", tt.linearPriority, got, tt.want) + } + } +} + +func TestPriorityToLinear(t *testing.T) { + config := DefaultMappingConfig() + + tests := []struct { + beadsPriority int + want int + }{ + {0, 1}, // Critical -> Urgent + {1, 2}, // High -> High + {2, 3}, // Medium -> Medium + {3, 4}, // Low -> Low + {4, 0}, // Backlog -> No priority + {5, 3}, // Unknown -> Medium (default) + } + + for _, tt := range tests { + got := PriorityToLinear(tt.beadsPriority, config) + if got != tt.want { + t.Errorf("PriorityToLinear(%d) = %d, want %d", tt.beadsPriority, got, tt.want) + } + } +} + +func TestStateToBeadsStatus(t *testing.T) { + config := DefaultMappingConfig() + + tests := []struct { + state *State + want types.Status + }{ + {nil, types.StatusOpen}, + {&State{Type: "backlog", Name: "Backlog"}, types.StatusOpen}, + {&State{Type: "unstarted", Name: "Todo"}, types.StatusOpen}, + {&State{Type: "started", Name: "In Progress"}, types.StatusInProgress}, + {&State{Type: "completed", Name: "Done"}, types.StatusClosed}, + {&State{Type: "canceled", Name: "Cancelled"}, types.StatusClosed}, + {&State{Type: "unknown", Name: "Unknown"}, types.StatusOpen}, // Default + } + + for _, tt := range tests { + got := StateToBeadsStatus(tt.state, config) + if got != tt.want { + stateName := "nil" + if tt.state != nil { + stateName = tt.state.Type + } + t.Errorf("StateToBeadsStatus(%s) = %v, want %v", stateName, got, tt.want) + } + } +} + +func TestParseBeadsStatus(t *testing.T) { + tests := []struct { + input string + want 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}, // Default + } + + for _, tt := range tests { + got := ParseBeadsStatus(tt.input) + if got != tt.want { + t.Errorf("ParseBeadsStatus(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestStatusToLinearStateType(t *testing.T) { + tests := []struct { + status types.Status + want string + }{ + {types.StatusOpen, "unstarted"}, + {types.StatusInProgress, "started"}, + {types.StatusBlocked, "started"}, + {types.StatusClosed, "completed"}, + {types.Status("unknown"), "unstarted"}, // Unknown -> default + } + + for _, tt := range tests { + got := StatusToLinearStateType(tt.status) + if got != tt.want { + t.Errorf("StatusToLinearStateType(%v) = %q, want %q", tt.status, got, tt.want) + } + } +} + +func TestLabelToIssueType(t *testing.T) { + config := DefaultMappingConfig() + + tests := []struct { + labels *Labels + want types.IssueType + }{ + {nil, types.TypeTask}, + {&Labels{Nodes: []Label{}}, types.TypeTask}, + {&Labels{Nodes: []Label{{Name: "bug"}}}, types.TypeBug}, + {&Labels{Nodes: []Label{{Name: "Bug"}}}, types.TypeBug}, + {&Labels{Nodes: []Label{{Name: "feature"}}}, types.TypeFeature}, + {&Labels{Nodes: []Label{{Name: "epic"}}}, types.TypeEpic}, + {&Labels{Nodes: []Label{{Name: "chore"}}}, types.TypeChore}, + {&Labels{Nodes: []Label{{Name: "random"}, {Name: "bug"}}}, types.TypeBug}, + {&Labels{Nodes: []Label{{Name: "contains-bug-keyword"}}}, types.TypeBug}, + } + + for _, tt := range tests { + got := LabelToIssueType(tt.labels, config) + if got != tt.want { + t.Errorf("LabelToIssueType(%v) = %v, want %v", tt.labels, got, tt.want) + } + } +} + +func TestParseIssueType(t *testing.T) { + tests := []struct { + input string + want types.IssueType + }{ + {"bug", types.TypeBug}, + {"BUG", types.TypeBug}, + {"feature", types.TypeFeature}, + {"task", types.TypeTask}, + {"epic", types.TypeEpic}, + {"chore", types.TypeChore}, + {"unknown", types.TypeTask}, // Default + } + + for _, tt := range tests { + got := ParseIssueType(tt.input) + if got != tt.want { + t.Errorf("ParseIssueType(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestRelationToBeadsDep(t *testing.T) { + config := DefaultMappingConfig() + + tests := []struct { + relationType string + want string + }{ + {"blocks", "blocks"}, + {"blockedBy", "blocks"}, + {"duplicate", "duplicates"}, + {"related", "related"}, + {"unknown", "related"}, // Default + } + + for _, tt := range tests { + got := RelationToBeadsDep(tt.relationType, config) + if got != tt.want { + t.Errorf("RelationToBeadsDep(%q) = %q, want %q", tt.relationType, got, tt.want) + } + } +} + +func TestIssueToBeads(t *testing.T) { + config := DefaultMappingConfig() + + linearIssue := &Issue{ + ID: "uuid-123", + Identifier: "PROJ-123", + Title: "Test Issue", + Description: "Test description", + URL: "https://linear.app/team/issue/PROJ-123/test-issue", + Priority: 2, // High + State: &State{Type: "started", Name: "In Progress"}, + Assignee: &User{Name: "John Doe", Email: "john@example.com"}, + Labels: &Labels{Nodes: []Label{{Name: "bug"}}}, + CreatedAt: "2024-01-15T10:00:00Z", + UpdatedAt: "2024-01-16T12:00:00Z", + } + + result := IssueToBeads(linearIssue, config) + issue := result.Issue.(*types.Issue) + + if issue.Title != "Test Issue" { + t.Errorf("Title = %q, want %q", issue.Title, "Test Issue") + } + if issue.Description != "Test description" { + t.Errorf("Description = %q, want %q", issue.Description, "Test description") + } + if issue.Priority != 1 { // High in beads + t.Errorf("Priority = %d, want 1", issue.Priority) + } + if issue.Status != types.StatusInProgress { + t.Errorf("Status = %v, want %v", issue.Status, types.StatusInProgress) + } + if issue.Assignee != "john@example.com" { + t.Errorf("Assignee = %q, want %q", issue.Assignee, "john@example.com") + } + if issue.IssueType != types.TypeBug { + t.Errorf("IssueType = %v, want %v", issue.IssueType, types.TypeBug) + } + if issue.ExternalRef == nil { + t.Error("ExternalRef should not be nil") + } +} + +func TestIssueToBeadsWithParent(t *testing.T) { + config := DefaultMappingConfig() + + linearIssue := &Issue{ + ID: "uuid-456", + Identifier: "PROJ-456", + Title: "Child Issue", + Description: "Child description", + URL: "https://linear.app/team/issue/PROJ-456", + Priority: 3, + State: &State{Type: "unstarted", Name: "Todo"}, + Parent: &Parent{ID: "uuid-123", Identifier: "PROJ-123"}, + CreatedAt: "2024-01-15T10:00:00Z", + UpdatedAt: "2024-01-16T12:00:00Z", + } + + result := IssueToBeads(linearIssue, config) + + if len(result.Dependencies) != 1 { + t.Fatalf("Expected 1 dependency, got %d", len(result.Dependencies)) + } + if result.Dependencies[0].Type != "parent-child" { + t.Errorf("Dependency type = %q, want %q", result.Dependencies[0].Type, "parent-child") + } + if result.Dependencies[0].FromLinearID != "PROJ-456" { + t.Errorf("FromLinearID = %q, want %q", result.Dependencies[0].FromLinearID, "PROJ-456") + } + if result.Dependencies[0].ToLinearID != "PROJ-123" { + t.Errorf("ToLinearID = %q, want %q", result.Dependencies[0].ToLinearID, "PROJ-123") + } +} + +func TestBuildLinearToLocalUpdates(t *testing.T) { + config := DefaultMappingConfig() + + linearIssue := &Issue{ + ID: "uuid-123", + Identifier: "PROJ-123", + Title: "Updated Title", + Description: "Updated description", + Priority: 1, // Urgent + State: &State{Type: "completed", Name: "Done"}, + Assignee: &User{Name: "Jane Doe", Email: "jane@example.com"}, + Labels: &Labels{Nodes: []Label{{Name: "feature"}, {Name: "priority"}}}, + UpdatedAt: "2024-01-20T15:00:00Z", + CompletedAt: "2024-01-20T14:00:00Z", + } + + updates := BuildLinearToLocalUpdates(linearIssue, config) + + if updates["title"] != "Updated Title" { + t.Errorf("title = %v, want %q", updates["title"], "Updated Title") + } + if updates["description"] != "Updated description" { + t.Errorf("description = %v, want %q", updates["description"], "Updated description") + } + if updates["priority"] != 0 { // Critical in beads + t.Errorf("priority = %v, want 0", updates["priority"]) + } + if updates["status"] != "closed" { + t.Errorf("status = %v, want %q", updates["status"], "closed") + } + if updates["assignee"] != "jane@example.com" { + t.Errorf("assignee = %v, want %q", updates["assignee"], "jane@example.com") + } + + labels, ok := updates["labels"].([]string) + if !ok || len(labels) != 2 { + t.Errorf("labels = %v, want 2 labels", updates["labels"]) + } +} + +func TestBuildLinearToLocalUpdatesNoAssignee(t *testing.T) { + config := DefaultMappingConfig() + + linearIssue := &Issue{ + ID: "uuid-123", + Identifier: "PROJ-123", + Title: "No Assignee", + Description: "Test", + Priority: 3, + State: &State{Type: "unstarted", Name: "Todo"}, + Assignee: nil, + UpdatedAt: "2024-01-20T15:00:00Z", + } + + updates := BuildLinearToLocalUpdates(linearIssue, config) + + if updates["assignee"] != "" { + t.Errorf("assignee = %v, want empty string", updates["assignee"]) + } +} + +// mockConfigLoader implements ConfigLoader for testing +type mockConfigLoader struct { + config map[string]string +} + +func (m *mockConfigLoader) GetAllConfig() (map[string]string, error) { + return m.config, nil +} + +func TestLoadMappingConfig(t *testing.T) { + loader := &mockConfigLoader{ + config: map[string]string{ + "linear.priority_map.0": "3", + "linear.state_map.custom": "in_progress", + "linear.label_type_map.story": "feature", + "linear.relation_map.parent": "parent-child", + }, + } + + config := LoadMappingConfig(loader) + + // Check custom priority mapping + if config.PriorityMap["0"] != 3 { + t.Errorf("PriorityMap[0] = %d, want 3", config.PriorityMap["0"]) + } + + // Check custom state mapping + if config.StateMap["custom"] != "in_progress" { + t.Errorf("StateMap[custom] = %s, want in_progress", config.StateMap["custom"]) + } + + // Check custom label type mapping + if config.LabelTypeMap["story"] != "feature" { + t.Errorf("LabelTypeMap[story] = %s, want feature", config.LabelTypeMap["story"]) + } + + // Check custom relation mapping + if config.RelationMap["parent"] != "parent-child" { + t.Errorf("RelationMap[parent] = %s, want parent-child", config.RelationMap["parent"]) + } + + // Check that defaults are preserved + if config.StateMap["started"] != "in_progress" { + t.Errorf("StateMap[started] = %s, want in_progress (default preserved)", config.StateMap["started"]) + } +} + +func TestLoadMappingConfigNilLoader(t *testing.T) { + config := LoadMappingConfig(nil) + + // Should return defaults + if config.PriorityMap["1"] != 0 { + t.Errorf("Expected default priority map with nil loader") + } +} + +func TestBuildLinearDescription(t *testing.T) { + tests := []struct { + name string + issue *types.Issue + want string + }{ + { + name: "description only", + issue: &types.Issue{Description: "Basic description"}, + want: "Basic description", + }, + { + name: "with acceptance criteria", + issue: &types.Issue{ + Description: "Main description", + AcceptanceCriteria: "- Must do X\n- Must do Y", + }, + want: "Main description\n\n## Acceptance Criteria\n- Must do X\n- Must do Y", + }, + { + name: "with all fields", + issue: &types.Issue{ + Description: "Main description", + AcceptanceCriteria: "AC here", + Design: "Design notes", + Notes: "Additional notes", + }, + want: "Main description\n\n## Acceptance Criteria\nAC here\n\n## Design\nDesign notes\n\n## Notes\nAdditional notes", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BuildLinearDescription(tt.issue) + if got != tt.want { + t.Errorf("BuildLinearDescription() = %q, want %q", got, tt.want) + } + }) + } +}