From e457b9d714b810ede25232b0973d9f93f0c84dc3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Jul 2026 11:08:41 +0000 Subject: [PATCH] fix(linear): avoid batch push abort on secondary team cache failures Co-authored-by: Kev Glynn --- internal/linear/tracker.go | 78 +++++++++++++++++++--------- internal/linear/tracker_test.go | 92 +++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 23 deletions(-) diff --git a/internal/linear/tracker.go b/internal/linear/tracker.go index 1c27e52af1..ba12405735 100644 --- a/internal/linear/tracker.go +++ b/internal/linear/tracker.go @@ -274,27 +274,6 @@ func (t *Tracker) BatchPush(ctx context.Context, issues []*types.Issue, forceIDs return nil, fmt.Errorf("no Linear client available") } - // Build per-team state caches so that updates to issues belonging to different - // teams resolve workflow state IDs against the correct team's state list. - teamCaches := make(map[string]*StateCache, len(t.teamIDs)) - for _, teamID := range t.teamIDs { - teamClient := t.clients[teamID] - if teamClient == nil { - continue - } - cache, err := BuildStateCache(ctx, teamClient) - if err != nil { - return nil, fmt.Errorf("building state cache for team %s: %w", teamID, err) - } - teamCaches[teamID] = cache - } - - // The primary team's cache is used for creates, which always target the primary team. - primaryCache := teamCaches[t.teamIDs[0]] - if primaryCache == nil { - return nil, fmt.Errorf("building state cache: no cache for primary team %s", t.teamIDs[0]) - } - result := &tracker.BatchPushResult{} var toCreate []*types.Issue @@ -312,6 +291,42 @@ func (t *Tracker) BatchPush(ctx context.Context, issues []*types.Issue, forceIDs } } + // Build per-team state caches lazily so a broken secondary team does not block + // pushes that only target healthy teams. + teamCaches := make(map[string]*StateCache, len(t.teamIDs)) + teamCacheErrs := make(map[string]error, len(t.teamIDs)) + resolveTeamCache := func(teamID string) (*StateCache, error) { + if cache, ok := teamCaches[teamID]; ok && cache != nil { + return cache, nil + } + if cachedErr, ok := teamCacheErrs[teamID]; ok { + return nil, cachedErr + } + teamClient := t.clients[teamID] + if teamClient == nil { + err := fmt.Errorf("no client for team %s", teamID) + teamCacheErrs[teamID] = err + return nil, err + } + cache, err := BuildStateCache(ctx, teamClient) + if err != nil { + err = fmt.Errorf("building state cache for team %s: %w", teamID, err) + teamCacheErrs[teamID] = err + return nil, err + } + teamCaches[teamID] = cache + return cache, nil + } + + var primaryCache *StateCache + if len(toCreate) > 0 { + var err error + primaryCache, err = resolveTeamCache(t.teamIDs[0]) + if err != nil { + return nil, err + } + } + // Batch create new issues. if len(toCreate) > 0 { // Partition into unique-title (safe for batch) and duplicate-title (single-create). @@ -438,8 +453,25 @@ func (t *Tracker) BatchPush(ctx context.Context, issues []*types.Issue, forceIDs // Use the per-team state cache so that multi-team setups resolve state IDs // against the correct team's workflow states, not the primary team's. - teamCache, ok := teamCaches[routeClient.TeamID] - if !ok || teamCache == nil { + teamCache, cacheErr := resolveTeamCache(routeClient.TeamID) + if cacheErr != nil { + result.Errors = append(result.Errors, tracker.BatchPushError{ + LocalID: issue.ID, + Message: cacheErr.Error(), + }) + continue + } + if teamCache == nil { + if primaryCache == nil { + primaryCache, cacheErr = resolveTeamCache(t.teamIDs[0]) + if cacheErr != nil { + result.Errors = append(result.Errors, tracker.BatchPushError{ + LocalID: issue.ID, + Message: cacheErr.Error(), + }) + continue + } + } teamCache = primaryCache // defensive fallback } diff --git a/internal/linear/tracker_test.go b/internal/linear/tracker_test.go index 1e97474db3..af5824735d 100644 --- a/internal/linear/tracker_test.go +++ b/internal/linear/tracker_test.go @@ -386,6 +386,98 @@ func TestBatchPush_PerTeamStateCache(t *testing.T) { } } +// TestBatchPush_DoesNotFailCreateWhenSecondaryTeamStateCacheFails verifies that +// a broken secondary team does not abort a create-only batch push. BatchPush +// should only need the primary team's state cache when all pushed issues are +// new (create path). +func TestBatchPush_DoesNotFailCreateWhenSecondaryTeamStateCacheFails(t *testing.T) { + var secondaryTeamStateCalls int + + // team-1 server: primary team, handles create path successfully. + team1Server := 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, "TeamStates"): + json.NewEncoder(w).Encode(teamStatesResp("team-1", "t1-state-open", "Backlog", "backlog")) + case strings.Contains(req.Query, "issueBatchCreate"): + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": map[string]interface{}{ + "issueBatchCreate": map[string]interface{}{ + "success": true, + "issues": []interface{}{ + map[string]interface{}{ + "id": "uuid-1", + "identifier": "TEAM-1", + "title": "Primary Create", + "url": "https://linear.app/team/issue/TEAM-1", + "priority": 0, + "state": map[string]interface{}{"id": "t1-state-open", "name": "Backlog", "type": "backlog"}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + }, + }, + }, + }, + }) + } + })) + defer team1Server.Close() + + // team-2 server: secondary team with failing state lookup. + team2Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req GraphQLRequest + _ = json.Unmarshal(body, &req) + + if strings.Contains(req.Query, "TeamStates") { + secondaryTeamStateCalls++ + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"boom"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"data": map[string]interface{}{}}) + })) + defer team2Server.Close() + + cfg := DefaultMappingConfig() + cfg.ExplicitStateMap = map[string]string{"backlog": "open"} + + tr := &Tracker{ + teamIDs: []string{"team-1", "team-2"}, + clients: map[string]*Client{ + "team-1": NewClient("key", "team-1").WithEndpoint(team1Server.URL), + "team-2": NewClient("key", "team-2").WithEndpoint(team2Server.URL), + }, + config: cfg, + } + + local := &types.Issue{ + ID: "local-create-1", + Title: "Primary Create", + Status: types.StatusOpen, + Priority: 4, + } + + result, err := tr.BatchPush(context.Background(), []*types.Issue{local}, nil) + if err != nil { + t.Fatalf("BatchPush: %v", err) + } + if len(result.Created) != 1 { + t.Fatalf("Created = %d, want 1; errors: %v", len(result.Created), result.Errors) + } + if len(result.Errors) != 0 { + t.Fatalf("Errors = %v, want none", result.Errors) + } + if secondaryTeamStateCalls != 0 { + t.Fatalf("secondary team state cache was queried %d times, want 0 for create-only push", secondaryTeamStateCalls) + } +} + // TestBatchPush_DuplicateTitlesFallbackToSingleCreate verifies that issues with // duplicate titles within a batch are routed through single-create with idempotency // markers instead of being sent through the batch mutation, where title-based