Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions internal/linear/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
113 changes: 111 additions & 2 deletions internal/linear/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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<!-- bd-idempotency: existing-marker -->",
},
{
TeamID: "test-team",
Title: "Brand New",
Description: "desc\n<!-- bd-idempotency: new-marker -->",
},
}

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.
Expand Down Expand Up @@ -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)
}
}

Expand Down
13 changes: 11 additions & 2 deletions internal/linear/tracker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}{
Expand Down Expand Up @@ -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.
Expand Down
Loading