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
78 changes: 55 additions & 23 deletions internal/linear/tracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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
}

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