diff --git a/internal/linear/client.go b/internal/linear/client.go index 075b84a3b2..34bbc72e82 100644 --- a/internal/linear/client.go +++ b/internal/linear/client.go @@ -924,22 +924,46 @@ func (c *Client) BatchCreateIssues(ctx context.Context, inputs []IssueCreateInpu } chunk := inputs[start:end] + // Pre-check idempotency markers before creating. This prevents duplicate + // Linear issues when a prior sync created the issue but failed to persist + // external_ref locally (or when an ambiguous batch failure was retried). + pending := make([]IssueCreateInput, 0, len(chunk)) + for _, input := range chunk { + marker := extractIdempotencyMarker(input.Description) + if marker == "" { + pending = append(pending, input) + continue + } + existing, lookupErr := c.FindIssueByDescriptionContains(ctx, marker) + if lookupErr != nil { + return allIssues, fmt.Errorf("idempotency precheck failed for %q: %w", input.Title, lookupErr) + } + if existing != nil { + allIssues = append(allIssues, *existing) + continue + } + pending = append(pending, input) + } + if len(pending) == 0 { + continue + } + req := &GraphQLRequest{ Query: query, Variables: map[string]interface{}{ - "input": chunk, + "input": pending, }, } data, err := c.Execute(ctx, req) if err != nil { - found, recoverErr := c.recoverAfterAmbiguousBatch(ctx, chunk) + found, recoverErr := c.recoverAfterAmbiguousBatch(ctx, pending) if recoverErr != nil { return allIssues, fmt.Errorf("batch create failed and recovery search also failed: %w (batch error: %v)", recoverErr, err) } allIssues = append(allIssues, found...) - if len(found) < len(chunk) { - return allIssues, fmt.Errorf("batch create failed; %d of %d issues unconfirmed (batch error: %v)", len(chunk)-len(found), len(chunk), err) + if len(found) < len(pending) { + return allIssues, fmt.Errorf("batch create failed; %d of %d issues unconfirmed (batch error: %v)", len(pending)-len(found), len(pending), err) } continue } @@ -950,13 +974,13 @@ func (c *Client) BatchCreateIssues(ctx context.Context, inputs []IssueCreateInpu } if !batchResp.IssueBatchCreate.Success { - found, recoverErr := c.recoverAfterAmbiguousBatch(ctx, chunk) + found, recoverErr := c.recoverAfterAmbiguousBatch(ctx, pending) if recoverErr != nil { return allIssues, fmt.Errorf("batch create unsuccessful and recovery search also failed: %w", recoverErr) } allIssues = append(allIssues, found...) - if len(found) < len(chunk) { - return allIssues, fmt.Errorf("batch create unsuccessful; %d of %d issues unconfirmed", len(chunk)-len(found), len(chunk)) + if len(found) < len(pending) { + return allIssues, fmt.Errorf("batch create unsuccessful; %d of %d issues unconfirmed", len(pending)-len(found), len(pending)) } continue } diff --git a/internal/linear/client_test.go b/internal/linear/client_test.go index d50d285075..2e390d8180 100644 --- a/internal/linear/client_test.go +++ b/internal/linear/client_test.go @@ -284,6 +284,115 @@ func TestBatchCreateIssues_Chunking(t *testing.T) { } } +// TestBatchCreateIssues_PrecheckSkipsExistingMarkers verifies that batch create +// performs an idempotency pre-check and reuses already-created issues (found by +// marker search) instead of creating duplicates. +func TestBatchCreateIssues_PrecheckSkipsExistingMarkers(t *testing.T) { + var mutationCount, searchCount, batchInputCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req GraphQLRequest + json.Unmarshal(body, &req) + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.Contains(req.Query, "FindByDescription"): + searchCount++ + filter := req.Variables["filter"].(map[string]interface{}) + desc := filter["description"].(map[string]interface{}) + searchText := desc["contains"].(string) + if strings.Contains(searchText, "existing-marker") { + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": map[string]interface{}{ + "issues": map[string]interface{}{ + "nodes": []interface{}{ + map[string]interface{}{ + "id": "existing-uuid", "identifier": "TEAM-100", + "title": "Already Exists", "url": "https://linear.app/team/issue/TEAM-100", + "priority": 0, "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", + }, + }, + "pageInfo": map[string]interface{}{"hasNextPage": false, "endCursor": ""}, + }, + }, + }) + return + } + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": map[string]interface{}{ + "issues": map[string]interface{}{ + "nodes": []interface{}{}, + "pageInfo": map[string]interface{}{"hasNextPage": false, "endCursor": ""}, + }, + }, + }) + case strings.Contains(req.Query, "issueBatchCreate"): + mutationCount++ + input := req.Variables["input"].([]interface{}) + batchInputCount += len(input) + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": map[string]interface{}{ + "issueBatchCreate": map[string]interface{}{ + "success": true, + "issues": []interface{}{ + map[string]interface{}{ + "id": "new-uuid", "identifier": "TEAM-101", + "title": "Brand New", "url": "https://linear.app/team/issue/TEAM-101", + "priority": 0, "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", + }, + }, + }, + }, + }) + default: + t.Fatalf("unexpected query: %s", req.Query) + } + })) + defer server.Close() + + client := NewClient("test-key", "test-team").WithEndpoint(server.URL) + inputs := []IssueCreateInput{ + { + TeamID: "test-team", + Title: "Already Exists", + Description: "desc\n", + }, + { + TeamID: "test-team", + Title: "Brand New", + Description: "desc\n", + }, + } + + issues, err := client.BatchCreateIssues(context.Background(), inputs) + if err != nil { + t.Fatalf("BatchCreateIssues failed: %v", err) + } + if searchCount != 2 { + t.Errorf("expected 2 marker searches, got %d", searchCount) + } + if mutationCount != 1 { + t.Errorf("expected 1 batch mutation, got %d", mutationCount) + } + if batchInputCount != 1 { + t.Errorf("expected only 1 pending input to batch create, got %d", batchInputCount) + } + if len(issues) != 2 { + t.Fatalf("expected 2 returned issues (1 reused + 1 new), got %d", len(issues)) + } + + byTitle := map[string]string{} + for _, issue := range issues { + byTitle[issue.Title] = issue.URL + } + if byTitle["Already Exists"] != "https://linear.app/team/issue/TEAM-100" { + t.Errorf("existing issue URL mismatch: got %q", byTitle["Already Exists"]) + } + if byTitle["Brand New"] != "https://linear.app/team/issue/TEAM-101" { + t.Errorf("new issue URL mismatch: got %q", byTitle["Brand New"]) + } +} + // TestBatchCreateIssues_AmbiguousFailureSearchesMarkers verifies that on batch // failure (success=false), the client searches for idempotency markers to find // which issues were partially created, instead of blindly retrying the full chunk. @@ -363,8 +472,8 @@ func TestBatchCreateIssues_AmbiguousFailureSearchesMarkers(t *testing.T) { if len(issues) != 1 { t.Errorf("expected 1 recovered issue, got %d", len(issues)) } - if searchCount != 2 { - t.Errorf("expected 2 marker searches, got %d", searchCount) + if searchCount != 3 { + t.Errorf("expected 3 marker searches (2 pre-check + 1 recovery), got %d", searchCount) } } diff --git a/internal/linear/tracker_test.go b/internal/linear/tracker_test.go index 1e97474db3..ad422cca1f 100644 --- a/internal/linear/tracker_test.go +++ b/internal/linear/tracker_test.go @@ -217,6 +217,15 @@ func TestBatchPush_BatchCreateMappingByTitle(t *testing.T) { switch { case strings.Contains(req.Query, "TeamStates"): json.NewEncoder(w).Encode(teamStatesResp("team-1", "state-open", "Backlog", "backlog")) + case strings.Contains(req.Query, "FindByDescription"): + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": map[string]interface{}{ + "issues": map[string]interface{}{ + "nodes": []interface{}{}, + "pageInfo": map[string]interface{}{"hasNextPage": false, "endCursor": ""}, + }, + }, + }) case strings.Contains(req.Query, "issueBatchCreate"): // Return the two issues in REVERSE order to expose index-based mapping bugs. json.NewEncoder(w).Encode(map[string]interface{}{ @@ -587,8 +596,8 @@ func TestBatchPush_AmbiguousBatchFailureSearchesMarkers(t *testing.T) { t.Fatalf("BatchPush: %v", err) } - if searchCount != 2 { - t.Errorf("marker searches = %d, want 2 (one per issue in the failed batch)", searchCount) + if searchCount != 3 { + t.Errorf("marker searches = %d, want 3 (2 pre-check + 1 recovery for pending issue)", searchCount) } // Issue A was found via marker search → should appear in Created.