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