Files
beads/internal/linear/mapping_test.go
Justin Williams df5ceb5d82 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
2025-12-19 17:58:24 -08:00

145 lines
3.5 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
}