Files
beads/internal/linear/mapping_test.go
Steve Yegge af432bee4e test(linear): add comprehensive tests for mapping and client functions
Added tests for:
- DefaultMappingConfig
- PriorityToBeads/PriorityToLinear
- StateToBeadsStatus/ParseBeadsStatus
- StatusToLinearStateType
- LabelToIssueType/ParseIssueType
- RelationToBeadsDep
- IssueToBeads (with parent/child handling)
- BuildLinearToLocalUpdates
- LoadMappingConfig
- BuildLinearDescription
- NewClient/WithEndpoint/WithHTTPClient
- ExtractLinearIdentifier
- IsLinearExternalRef

Linear package coverage improved from 14.3% to ~50%.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 18:30:03 -08:00

602 lines
16 KiB
Go

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
}
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)
}
})
}
}