From c50f9f0bcdc9841fa300593c0e9b3824a9b4c4df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 11:03:47 +0000 Subject: [PATCH 1/2] fix(linear): avoid full batch failure from unused team cache Co-authored-by: Medhaug --- internal/linear/tracker.go | 37 +++++++------- internal/linear/tracker_test.go | 85 +++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 20 deletions(-) diff --git a/internal/linear/tracker.go b/internal/linear/tracker.go index 1c27e52af1..214ffd09ed 100644 --- a/internal/linear/tracker.go +++ b/internal/linear/tracker.go @@ -274,26 +274,14 @@ 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]) + // Build the primary team's state cache up-front for creates. + primaryCache, err := BuildStateCache(ctx, client) + if err != nil { + return nil, fmt.Errorf("building state cache for primary team %s: %w", client.TeamID, err) } + // Non-primary team caches are loaded lazily only when an issue routes there, + // so a broken/unused team does not block unrelated creates/updates. + teamCaches := map[string]*StateCache{client.TeamID: primaryCache} result := &tracker.BatchPushResult{} @@ -440,7 +428,16 @@ func (t *Tracker) BatchPush(ctx context.Context, issues []*types.Issue, forceIDs // against the correct team's workflow states, not the primary team's. teamCache, ok := teamCaches[routeClient.TeamID] if !ok || teamCache == nil { - teamCache = primaryCache // defensive fallback + cache, cacheErr := BuildStateCache(ctx, routeClient) + if cacheErr != nil { + result.Errors = append(result.Errors, tracker.BatchPushError{ + LocalID: issue.ID, + Message: fmt.Sprintf("building state cache for team %s: %v", routeClient.TeamID, cacheErr), + }) + continue + } + teamCache = cache + teamCaches[routeClient.TeamID] = teamCache } // Skip issues that haven't changed since the last push, unless forced. diff --git a/internal/linear/tracker_test.go b/internal/linear/tracker_test.go index 1e97474db3..d099068e49 100644 --- a/internal/linear/tracker_test.go +++ b/internal/linear/tracker_test.go @@ -386,6 +386,91 @@ func TestBatchPush_PerTeamStateCache(t *testing.T) { } } +// TestBatchPush_IgnoresUnusedBrokenTeamCache verifies that a broken secondary +// team does not abort a batch push when all pushed issues target the primary +// team. This guards against full-sync failure in multi-team configs when an +// unrelated team is temporarily unavailable. +func TestBatchPush_IgnoresUnusedBrokenTeamCache(t *testing.T) { + var team2StateCalls int + + 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", "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 Team Issue", + "url": "https://linear.app/team/issue/TEAM-1", + "priority": 0, + "state": map[string]interface{}{"id": "state-open", "name": "Backlog", "type": "backlog"}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + }, + }, + }, + }, + }) + } + })) + defer team1Server.Close() + + team2Server := 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") + if strings.Contains(req.Query, "TeamStates") { + team2StateCalls++ + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"errors":[{"message":"team-2 unavailable"}]}`)) + return + } + })) + 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 Team Issue", + Status: types.StatusOpen, + Priority: 4, + } + + result, err := tr.BatchPush(context.Background(), []*types.Issue{local}, nil) + if err != nil { + t.Fatalf("BatchPush returned unexpected error: %v", err) + } + if len(result.Created) != 1 { + t.Fatalf("Created = %d, want 1; errors: %v", len(result.Created), result.Errors) + } + if team2StateCalls != 0 { + t.Fatalf("unexpected TeamStates call for unused team-2: %d", team2StateCalls) + } +} + // 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 From 0cd34b12bd66608b8c41130581305fc240f2d531 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 11:09:41 +0000 Subject: [PATCH 2/2] fix(doctor): fail closed on conflicting server identity signals Co-authored-by: Medhaug --- cmd/bd/doctor/fix/metadata.go | 16 ++++++++++++++++ cmd/bd/doctor/fix/metadata_test.go | 29 ++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/cmd/bd/doctor/fix/metadata.go b/cmd/bd/doctor/fix/metadata.go index 0cf47d5328..67848296d0 100644 --- a/cmd/bd/doctor/fix/metadata.go +++ b/cmd/bd/doctor/fix/metadata.go @@ -364,6 +364,22 @@ func reconcileAuthoritativeServerMetadata(cfg *configfile.Config, databases []se strings.Join(names, ", "), ) } + currentName := cfg.GetDoltDatabase() + current, ok := byName[currentName] + if len(matches) == 1 && + ok && + current.HasSchema && + current.ProjectID != "" && + current.ProjectID != cfg.ProjectID && + matches[0].Name != currentName { + return false, "", fmt.Errorf( + "conflicting authoritative identity signals: metadata project_id %s maps to %q, but configured dolt_database %q has project_id %s", + cfg.ProjectID, + matches[0].Name, + currentName, + current.ProjectID, + ) + } if len(matches) == 1 && cfg.DoltDatabase != matches[0].Name { from := cfg.GetDoltDatabase() cfg.DoltDatabase = matches[0].Name diff --git a/cmd/bd/doctor/fix/metadata_test.go b/cmd/bd/doctor/fix/metadata_test.go index 2c94dd2ce0..3ba145b31f 100644 --- a/cmd/bd/doctor/fix/metadata_test.go +++ b/cmd/bd/doctor/fix/metadata_test.go @@ -228,7 +228,9 @@ func TestReconcileAuthoritativeServerMetadata_UsesProjectIDToRepairDatabaseName( } changed, msg, err := reconcileAuthoritativeServerMetadata(cfg, []serverDatabaseMetadata{ - {Name: "wrong_db", HasSchema: true, ProjectID: "other-proj"}, + // wrong_db exists but is not an authoritative beads database, so + // project_id matching can safely repair dolt_database. + {Name: "wrong_db", HasSchema: false, ProjectID: "other-proj"}, {Name: "canonical_db", HasSchema: true, ProjectID: "proj-123"}, }) if err != nil { @@ -245,6 +247,31 @@ func TestReconcileAuthoritativeServerMetadata_UsesProjectIDToRepairDatabaseName( } } +func TestReconcileAuthoritativeServerMetadata_ErrorsOnConflictingAuthoritativeSignals(t *testing.T) { + cfg := &configfile.Config{ + DoltMode: configfile.DoltModeServer, + DoltDatabase: "project_b_db", + ProjectID: "project-a-id", + } + + changed, msg, err := reconcileAuthoritativeServerMetadata(cfg, []serverDatabaseMetadata{ + {Name: "project_a_db", HasSchema: true, ProjectID: "project-a-id"}, + {Name: "project_b_db", HasSchema: true, ProjectID: "project-b-id"}, + }) + if err == nil { + t.Fatal("expected conflict error, got nil") + } + if changed { + t.Fatal("changed = true, want false on conflict") + } + if msg != "" { + t.Fatalf("msg = %q, want empty", msg) + } + if !strings.Contains(err.Error(), "conflicting authoritative identity signals") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestReconcileAuthoritativeServerMetadata_AdoptsConfiguredDatabaseProjectID(t *testing.T) { cfg := &configfile.Config{ DoltMode: configfile.DoltModeServer,