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
48 changes: 40 additions & 8 deletions internal/linear/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -748,7 +748,11 @@ func (c *Client) createIssueSingleAttempt(ctx context.Context, title, descriptio
}

httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", c.APIKey)
authValue, err := c.authHeader()
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", authValue)

resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
Expand Down Expand Up @@ -885,6 +889,11 @@ func (c *Client) UpdateIssue(ctx context.Context, issueID string, updates map[st
// BatchCreateIssues creates multiple issues in Linear using the issueBatchCreate mutation.
// Inputs are chunked into groups of BatchSize (50).
//
// Before calling issueBatchCreate, this method pre-checks idempotency markers in
// each input description and reuses already-created issues. This prevents
// duplicate creates when a prior sync already created the issue but failed before
// saving local external_refs.
//
// On ambiguous failure (API error or success=false), this method does NOT blindly
// retry the full chunk—Linear may have partially applied the mutation. Instead it
// searches for each issue's idempotency marker (embedded in the description) to
Expand Down Expand Up @@ -923,23 +932,46 @@ func (c *Client) BatchCreateIssues(ctx context.Context, inputs []IssueCreateInpu
end = len(inputs)
}
chunk := inputs[start:end]
pending := make([]IssueCreateInput, 0, len(chunk))
for _, input := range chunk {
marker := extractIdempotencyMarker(input.Description)
if marker == "" {
pending = append(pending, input)
continue
}

existing, err := c.FindIssueByDescriptionContains(ctx, marker)
if err != nil {
return allIssues, fmt.Errorf("idempotency pre-check failed for %q: %w", input.Title, err)
}
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 +982,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
121 changes: 119 additions & 2 deletions internal/linear/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,123 @@ func TestBatchCreateIssues_Chunking(t *testing.T) {
}
}

// TestBatchCreateIssues_PrecheckSkipsExistingMarkers verifies that existing
// idempotency markers are checked before batch mutation, so reruns don't
// create duplicates when an issue already exists in Linear.
func TestBatchCreateIssues_PrecheckSkipsExistingMarkers(t *testing.T) {
var searchCount, batchCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req GraphQLRequest
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("failed to unmarshal request: %v", err)
}
w.Header().Set("Content-Type", "application/json")

if 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, "marker-existing") {
_ = 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-1",
"title": "Already Exists", "url": "https://linear.app/team/issue/TEAM-1",
"priority": 1, "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": ""},
},
},
})
return
}

if strings.Contains(req.Query, "issueBatchCreate") {
batchCount++
inputs := req.Variables["input"].([]interface{})
if len(inputs) != 1 {
t.Fatalf("expected only unresolved input in batch, got %d", len(inputs))
}
input0 := inputs[0].(map[string]interface{})
if input0["title"] != "New Issue" {
t.Fatalf("unexpected batch input title: %v", input0["title"])
}
_ = 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-2",
"title": "New Issue", "url": "https://linear.app/team/issue/TEAM-2",
"priority": 2, "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z",
},
},
},
},
})
return
}

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: marker-existing -->",
},
{
TeamID: "test-team",
Title: "New Issue",
Description: "desc\n<!-- bd-idempotency: marker-new -->",
},
}

issues, err := client.BatchCreateIssues(context.Background(), inputs)
if err != nil {
t.Fatalf("BatchCreateIssues failed: %v", err)
}
if searchCount != 2 {
t.Errorf("expected 2 marker pre-check searches, got %d", searchCount)
}
if batchCount != 1 {
t.Errorf("expected 1 batch create call, got %d", batchCount)
}
if len(issues) != 2 {
t.Fatalf("expected 2 issues total, got %d", len(issues))
}

byTitle := make(map[string]Issue, len(issues))
for _, issue := range issues {
byTitle[issue.Title] = issue
}
if _, ok := byTitle["Already Exists"]; !ok {
t.Errorf("missing prechecked existing issue in results: %+v", issues)
}
if _, ok := byTitle["New Issue"]; !ok {
t.Errorf("missing newly created issue in results: %+v", issues)
}
}

// 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 +480,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
98 changes: 98 additions & 0 deletions internal/linear/idempotency_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,104 @@ func TestCreateIssueEmbedsMarker(t *testing.T) {
}
}

func TestCreateIssueIdempotentOAuthUsesBearerHeader(t *testing.T) {
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "lin_oauth_create",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read write",
})
}))
defer tokenServer.Close()

var createAuth string
apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req GraphQLRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("failed to decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")

if strings.Contains(req.Query, "FindByDescription") {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"issues": map[string]interface{}{
"nodes": []Issue{},
"pageInfo": map[string]interface{}{"hasNextPage": false, "endCursor": ""},
},
},
})
return
}

if strings.Contains(req.Query, "issueCreate") {
createAuth = r.Header.Get("Authorization")
if createAuth != "Bearer lin_oauth_create" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
return
}

input, _ := req.Variables["input"].(map[string]interface{})
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"issueCreate": map[string]interface{}{
"success": true,
"issue": map[string]interface{}{
"id": "oauth-uuid",
"identifier": "TEAM-500",
"title": input["title"],
"description": input["description"],
"url": "https://linear.app/team/issue/TEAM-500",
"priority": input["priority"],
"state": map[string]interface{}{
"id": "state-1",
"name": "Todo",
"type": "unstarted",
},
"createdAt": "2026-05-01T10:00:00Z",
"updatedAt": "2026-05-01T10:00:00Z",
},
},
},
})
return
}

t.Fatalf("unexpected query: %s", req.Query)
}))
defer apiServer.Close()

client := NewOAuthClient(OAuthConfig{
ClientID: "id",
ClientSecret: "secret",
TokenURL: tokenServer.URL,
}, "team-1").WithEndpoint(apiServer.URL)

marker := GenerateIdempotencyMarker("bead-oauth", "dev@test.com", 123)
issue, deduped, err := client.CreateIssueIdempotent(
context.Background(),
"OAuth Marker Issue",
"desc",
2, "", nil,
marker,
)
if err != nil {
t.Fatalf("CreateIssueIdempotent failed: %v", err)
}
if deduped {
t.Error("expected deduped=false for fresh create")
}
if issue == nil || issue.Identifier != "TEAM-500" {
t.Fatalf("unexpected created issue: %+v", issue)
}
if createAuth != "Bearer lin_oauth_create" {
t.Errorf("Authorization header = %q, want %q", createAuth, "Bearer lin_oauth_create")
}
}

func TestCreateIssueDedups(t *testing.T) {
existing := Issue{
ID: "existing-uuid",
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 (two pre-checks + one recovery search)", searchCount)
}

// Issue A was found via marker search → should appear in Created.
Expand Down
Loading