From 8f105a507a1b9ee550db65e4c778d4dbede8f387 Mon Sep 17 00:00:00 2001 From: Javier David Carnelli Date: Mon, 24 Aug 2026 18:28:00 -0300 Subject: [PATCH 1/8] fix: pin ticket-schema project window size across a caller page-size change ListTicketSchemas derived the /project/search page size from the caller's p.Size on every call, but its resume token only recorded a page-relative project index, not the size that index was computed against. C1's driver shrinks the requested page size as ListTicketSchemas accumulates toward its 100-schema cap, so a resume could refetch a smaller window at the same offset than the one the stashed index was valid for: the index landed past the end of the new window (dropping the rest of it), and the offset then advanced by the shrunk window length instead of the true position, causing part of the window to be re-emitted on the next call. Stash the window size (ProjectPageSize) alongside the resume index so a mid-window resume always refetches the identical window, regardless of what page size the caller sends on that call. A freshly started window (after the prior one is fully consumed) is unaffected and still sizes off the caller's current request. Fixes CXP-936. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/tickets.go | 20 ++++++-- pkg/connector/tickets_test.go | 88 ++++++++++++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/pkg/connector/tickets.go b/pkg/connector/tickets.go index 7862efc4..1a00651d 100644 --- a/pkg/connector/tickets.go +++ b/pkg/connector/tickets.go @@ -44,6 +44,7 @@ var projectScopedCustomFieldIDs = map[string]bool{ // multiple calls by pairCap doesn't refetch them for every remaining page. type ticketSchemaPageToken struct { ProjectOffset int `json:"project_offset,omitempty"` + ProjectPageSize int `json:"project_page_size,omitempty"` ProjectIndexInPage int `json:"project_index,omitempty"` IssueTypeIndex int `json:"issue_type_index,omitempty"` Statuses []*v2.TicketStatus `json:"statuses,omitempty"` @@ -413,10 +414,16 @@ func (j *Jira) ListTicketSchemas(ctx context.Context, p *pagination.Token) ([]*v } } - // /project/search clamps maxResults server-side, so only honor a smaller caller size. - projectPageSize := resourcePageSize - if p != nil && p.Size > 0 && p.Size < resourcePageSize { - projectPageSize = p.Size + // /project/search clamps maxResults server-side, so only honor a smaller caller size + // when starting a fresh window. A resume mid-window must refetch the exact same window + // that the stashed ProjectIndexInPage was computed against, regardless of what page size + // the caller sends on the resuming call (C1's driver shrinks it as it nears a result cap). + projectPageSize := tok.ProjectPageSize + if projectPageSize <= 0 { + projectPageSize = resourcePageSize + if p != nil && p.Size > 0 && p.Size < resourcePageSize { + projectPageSize = p.Size + } } projects, resp, err := j.client.Jira().Project.Find(ctx, jira.WithStartAt(tok.ProjectOffset), jira.WithMaxResults(projectPageSize), jira.WithExpand("issueTypes"), jira.WithKeys(j.projectKeys...)) @@ -443,7 +450,10 @@ func (j *Jira) ListTicketSchemas(ctx context.Context, p *pagination.Token) ([]*v nextPageTokenAt := func(projectIndex, issueTypeIndex int, statuses []*v2.TicketStatus) (string, error) { return marshalTicketSchemaPageToken(ticketSchemaPageToken{ - ProjectOffset: tok.ProjectOffset, + ProjectOffset: tok.ProjectOffset, + // Lock in the window size this call fetched, so the resume that consumes this + // token refetches the identical window instead of one sized off its own p.Size. + ProjectPageSize: projectPageSize, ProjectIndexInPage: projectIndex, IssueTypeIndex: issueTypeIndex, Statuses: statuses, diff --git a/pkg/connector/tickets_test.go b/pkg/connector/tickets_test.go index 431d2c5a..4443ede9 100644 --- a/pkg/connector/tickets_test.go +++ b/pkg/connector/tickets_test.go @@ -59,7 +59,14 @@ func newTicketSchemaServer(t *testing.T, projects []ticketProjectFixture, projec switch r.URL.Path { case "/rest/api/2/project/search": startAt, _ := strconv.Atoi(r.URL.Query().Get("startAt")) - end := startAt + projectPageSize + // Honor the caller's maxResults like real Jira does, falling back to the + // configured default only if none was sent. A mock that ignores maxResults + // can't reproduce bugs triggered by the requested page size changing between calls. + maxResults := projectPageSize + if mr, err := strconv.Atoi(r.URL.Query().Get("maxResults")); err == nil && mr > 0 { + maxResults = mr + } + end := startAt + maxResults if end > len(projects) { end = len(projects) } @@ -87,7 +94,7 @@ func newTicketSchemaServer(t *testing.T, projects []ticketProjectFixture, projec _ = json.NewEncoder(w).Encode(map[string]interface{}{ "startAt": startAt, - "maxResults": projectPageSize, + "maxResults": maxResults, "total": len(projects), "values": values, }) @@ -431,6 +438,83 @@ func TestListTicketSchemas_CapNeverExceeded(t *testing.T) { t.Fatal("pagination did not terminate in time") } +// TestListTicketSchemas_SurvivesShrinkingCallerPageSize is the CXP-936 regression: C1's +// driver shrinks the caller's page size as it nears its own result cap (e.g. 8, 8, 4, 4, ...). +// The project-window size used to compute a resumed ProjectIndexInPage must stay pinned to +// what it was when that index was stashed, not be recomputed from whatever the resuming +// call happens to send - otherwise a resume can land past the end of a smaller refetched +// window, silently drop the rest of that window, and duplicate part of it on the call after. +func TestListTicketSchemas_SurvivesShrinkingCallerPageSize(t *testing.T) { + const numProjects = 10 + const issueTypesPerProject = 2 + + projects := make([]ticketProjectFixture, 0, numProjects) + for i := 0; i < numProjects; i++ { + projects = append(projects, buildManyIssueTypesProject( + fmt.Sprintf("P%d", i), fmt.Sprintf("%d", i+1), issueTypesPerProject)) + } + // Fixture issue type names collide across projects (Type1, Type2); schema IDs are + // projectKey:issueTypeID, so they stay unique even though names repeat. + + srv := newTicketSchemaServer(t, projects, resourcePageSize, nil) + defer srv.Close() + + j := newTestJira(t, srv.URL) + j.maxIssueTypePairsPerPage = 3 // force several resumes per project window + ctx := ctxzap.ToContext(context.Background(), zap.NewNop()) + + // Mimic C1's driver: page size shrinks toward a result cap as results accumulate. + // The shrink from 8 to 2 must land below the project index already reached inside + // the size-8 window (index 3), which is what actually triggers the defect. + callerSizes := []int{8, 8, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2} + + seen := map[string]int{} + var order []string + var token *pagination.Token + totalPairs := numProjects * issueTypesPerProject + + for call := 0; ; call++ { + if call >= len(callerSizes) { + t.Fatalf("pagination did not terminate within %d calls", len(callerSizes)) + } + size := callerSizes[call] + if token == nil { + token = &pagination.Token{Size: size} + } else { + token = &pagination.Token{Size: size, Token: token.Token} + } + + schemas, nextToken, _, err := j.ListTicketSchemas(ctx, token) + if err != nil { + t.Fatalf("call %d (size=%d): unexpected error: %v", call, size, err) + } + for _, s := range schemas { + seen[s.Id]++ + order = append(order, s.Id) + } + + if nextToken == "" { + break + } + if nextToken == token.Token { + t.Fatalf("call %d: next page token repeated (%q) - infinite loop risk", call, nextToken) + } + token = &pagination.Token{Token: nextToken} + } + + if len(order) != totalPairs { + t.Fatalf("expected %d total schemas emitted across all pages, got %d: %v", totalPairs, len(order), order) + } + for id, count := range seen { + if count != 1 { + t.Errorf("schema %s emitted %d times, want exactly 1 (duplicate caused by a page-size change mid-enumeration)", id, count) + } + } + if len(seen) != totalPairs { + t.Errorf("expected %d distinct schemas, got %d (tail lost after a page-size change mid-enumeration)", totalPairs, len(seen)) + } +} + func TestListTicketSchemas_ResumesMidProjectNotFromZero(t *testing.T) { projects := []ticketProjectFixture{buildManyIssueTypesProject("MID", "1", 5)} srv := newTicketSchemaServer(t, projects, 50, nil) From 74c0fd851cd2d6915065f49640abc2a5f62aaf39 Mon Sep 17 00:00:00 2001 From: Javier David Carnelli Date: Tue, 25 Aug 2026 22:22:11 -0300 Subject: [PATCH 2/8] chore: address PR comments --- pkg/connector/tickets.go | 18 ++++ pkg/connector/tickets_test.go | 151 ++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/pkg/connector/tickets.go b/pkg/connector/tickets.go index 1a00651d..d4406009 100644 --- a/pkg/connector/tickets.go +++ b/pkg/connector/tickets.go @@ -448,6 +448,24 @@ func (j *Jira) ListTicketSchemas(ctx context.Context, p *pagination.Token) ([]*v issueTypeIndex := tok.IssueTypeIndex resumedProjectIndex := tok.ProjectIndexInPage + if projectIndex > 0 && projectIndex >= len(projects) { + l.Debug( + "ticket schema project window shrank on resume, advancing to next window", + zap.Int("project_offset", tok.ProjectOffset), + zap.Int("stashed_project_index", projectIndex), + zap.Int("returned_project_count", len(projects)), + ) + + nextPageToken, err := marshalTicketSchemaPageToken(ticketSchemaPageToken{ + ProjectOffset: tok.ProjectOffset + len(projects), + }) + if err != nil { + return nil, "", nil, err + } + + return ret, nextPageToken, nil, nil + } + nextPageTokenAt := func(projectIndex, issueTypeIndex int, statuses []*v2.TicketStatus) (string, error) { return marshalTicketSchemaPageToken(ticketSchemaPageToken{ ProjectOffset: tok.ProjectOffset, diff --git a/pkg/connector/tickets_test.go b/pkg/connector/tickets_test.go index 4443ede9..a2735a85 100644 --- a/pkg/connector/tickets_test.go +++ b/pkg/connector/tickets_test.go @@ -515,6 +515,157 @@ func TestListTicketSchemas_SurvivesShrinkingCallerPageSize(t *testing.T) { } } +// TestListTicketSchemas_GuardsShrinkingResumeWindow covers the guard added after CXP-936: if +// the pinned project window returns fewer projects on a resumed call than it did when the +// token was issued (a project was deleted, or access to it was lost, mid-sync), the stashed +// ProjectIndexInPage can land past the end of the shrunk window. Without the guard, the resume +// loop never executes, the shrunk window looks like the last page, and ListTicketSchemas +// returns an empty page with an empty next-page token - silently ending the whole sync and +// dropping every remaining project instead of just the one that disappeared. +func TestListTicketSchemas_GuardsShrinkingResumeWindow(t *testing.T) { + p1 := buildManyIssueTypesProject("P1", "1", 2) + p2 := buildManyIssueTypesProject("P2", "2", 2) + p3 := buildManyIssueTypesProject("P3", "3", 2) + full := []ticketProjectFixture{p1, p2, p3} + shrunk := []ticketProjectFixture{p1} // P2 and P3 vanish before the resume is served + + byKeyOrID := func(projects []ticketProjectFixture, idOrKey string) *ticketProjectFixture { + for i := range projects { + if projects[i].key == idOrKey || projects[i].id == idOrKey { + return &projects[i] + } + } + return nil + } + + searchCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + active := full + if searchCalls > 0 { + active = shrunk + } + + switch { + case r.URL.Path == "/rest/api/2/project/search": + searchCalls++ + startAt, _ := strconv.Atoi(r.URL.Query().Get("startAt")) + end := len(active) + if startAt > end { + startAt = end + } + page := active[startAt:end] + + values := make([]map[string]interface{}, 0, len(page)) + for _, p := range page { + issueTypes := make([]map[string]interface{}, 0, len(p.issueTypes)) + for _, it := range p.issueTypes { + issueTypes = append(issueTypes, map[string]interface{}{ + "id": it.id, "name": it.name, "subtask": it.subtask, + }) + } + values = append(values, map[string]interface{}{ + "id": p.id, "key": p.key, "name": p.name, "issueTypes": issueTypes, + }) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "startAt": startAt, "maxResults": len(page), "total": len(active), "values": values, + }) + + case r.URL.Path == "/rest/api/3/statuses/search": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "startAt": 0, "maxResults": 100, "total": 0, "values": []map[string]interface{}{}, + }) + + default: + var projectIDOrKey, issueTypeID string + if n, _ := fmt.Sscanf(r.URL.Path, "/rest/api/2/issue/createmeta/%s", &projectIDOrKey); n == 1 { + parts := splitLast(projectIDOrKey, "/issuetypes/") + projectIDOrKey, issueTypeID = parts[0], parts[1] + } + p := byKeyOrID(active, projectIDOrKey) + if p == nil { + t.Errorf("unexpected create-meta request for project %s", projectIDOrKey) + w.WriteHeader(http.StatusNotFound) + return + } + var it *ticketIssueType + for i := range p.issueTypes { + if p.issueTypes[i].id == issueTypeID { + it = &p.issueTypes[i] + break + } + } + if it == nil { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "startAt": 0, "maxResults": 100, "total": len(it.fields), "fields": it.fields, + }) + } + })) + defer srv.Close() + + j := newTestJira(t, srv.URL) + j.maxIssueTypePairsPerPage = 2 // exhausts P1's 2 pairs, stashing mid-window at P2 (index 1) + ctx := ctxzap.ToContext(context.Background(), zap.NewNop()) + + // First call: full 3-project window; the cap hits right after P1, stashing + // ProjectIndexInPage=1 (P2) against a window pinned at size 3. + first, nextToken, _, err := j.ListTicketSchemas(ctx, &pagination.Token{Size: 3}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(first) != 2 { + t.Fatalf("expected 2 schemas from P1, got %d", len(first)) + } + if nextToken == "" { + t.Fatal("expected a next page token (P2/P3 still pending)") + } + + // Resume: the pinned window now only returns P1 (P2/P3 disappeared), so the stashed + // index (1) is out of bounds. The guard must log a warning and advance the window + // instead of falling through to the terminating return. + core, logs := observer.New(zap.WarnLevel) + warnCtx := ctxzap.ToContext(context.Background(), zap.New(core)) + + second, nextToken2, _, err := j.ListTicketSchemas(warnCtx, &pagination.Token{Size: 3, Token: nextToken}) + if err != nil { + t.Fatalf("unexpected error on resume: %v", err) + } + if len(second) != 0 { + t.Fatalf("expected no schemas on the guard call, got %d", len(second)) + } + if nextToken2 == "" { + t.Fatal("expected pagination to continue past the shrunk window, got empty next token") + } + + foundWarn := false + for _, entry := range logs.All() { + if entry.Message == "ticket schema project window shrank on resume, advancing to next window" { + foundWarn = true + } + } + if !foundWarn { + t.Error("expected a Warn log for the shrunk resume window") + } + + // Final call: the underlying data set is now exhausted, so pagination must terminate + // cleanly rather than looping or erroring. + third, nextToken3, _, err := j.ListTicketSchemas(ctx, &pagination.Token{Size: 3, Token: nextToken2}) + if err != nil { + t.Fatalf("unexpected error on final call: %v", err) + } + if len(third) != 0 { + t.Fatalf("expected no schemas past the end of the shrunk data set, got %d", len(third)) + } + if nextToken3 != "" { + t.Fatalf("expected pagination to terminate, got next token %q", nextToken3) + } +} + func TestListTicketSchemas_ResumesMidProjectNotFromZero(t *testing.T) { projects := []ticketProjectFixture{buildManyIssueTypesProject("MID", "1", 5)} srv := newTicketSchemaServer(t, projects, 50, nil) From 0aad2ea970cc227925704b3501d7046a76839bf3 Mon Sep 17 00:00:00 2001 From: Javier David Carnelli Date: Tue, 25 Aug 2026 22:31:28 -0300 Subject: [PATCH 3/8] test: fix log observer --- pkg/connector/tickets_test.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/connector/tickets_test.go b/pkg/connector/tickets_test.go index a2735a85..518e2629 100644 --- a/pkg/connector/tickets_test.go +++ b/pkg/connector/tickets_test.go @@ -626,12 +626,12 @@ func TestListTicketSchemas_GuardsShrinkingResumeWindow(t *testing.T) { } // Resume: the pinned window now only returns P1 (P2/P3 disappeared), so the stashed - // index (1) is out of bounds. The guard must log a warning and advance the window + // index (1) is out of bounds. The guard must log at Debug and advance the window // instead of falling through to the terminating return. - core, logs := observer.New(zap.WarnLevel) - warnCtx := ctxzap.ToContext(context.Background(), zap.New(core)) + core, logs := observer.New(zap.DebugLevel) + debugCtx := ctxzap.ToContext(context.Background(), zap.New(core)) - second, nextToken2, _, err := j.ListTicketSchemas(warnCtx, &pagination.Token{Size: 3, Token: nextToken}) + second, nextToken2, _, err := j.ListTicketSchemas(debugCtx, &pagination.Token{Size: 3, Token: nextToken}) if err != nil { t.Fatalf("unexpected error on resume: %v", err) } @@ -642,14 +642,14 @@ func TestListTicketSchemas_GuardsShrinkingResumeWindow(t *testing.T) { t.Fatal("expected pagination to continue past the shrunk window, got empty next token") } - foundWarn := false + foundDebug := false for _, entry := range logs.All() { if entry.Message == "ticket schema project window shrank on resume, advancing to next window" { - foundWarn = true + foundDebug = true } } - if !foundWarn { - t.Error("expected a Warn log for the shrunk resume window") + if !foundDebug { + t.Error("expected a Debug log for the shrunk resume window") } // Final call: the underlying data set is now exhausted, so pagination must terminate From 375a23c28c25f747c230aa96f4cc7dba521be83c Mon Sep 17 00:00:00 2001 From: Javier David Carnelli Date: Wed, 26 Aug 2026 00:08:04 -0300 Subject: [PATCH 4/8] chore: lint errors --- pkg/connector/tickets_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/connector/tickets_test.go b/pkg/connector/tickets_test.go index 518e2629..f9b91009 100644 --- a/pkg/connector/tickets_test.go +++ b/pkg/connector/tickets_test.go @@ -547,8 +547,8 @@ func TestListTicketSchemas_GuardsShrinkingResumeWindow(t *testing.T) { active = shrunk } - switch { - case r.URL.Path == "/rest/api/2/project/search": + switch r.URL.Path { + case "/rest/api/2/project/search": searchCalls++ startAt, _ := strconv.Atoi(r.URL.Query().Get("startAt")) end := len(active) @@ -573,7 +573,7 @@ func TestListTicketSchemas_GuardsShrinkingResumeWindow(t *testing.T) { "startAt": startAt, "maxResults": len(page), "total": len(active), "values": values, }) - case r.URL.Path == "/rest/api/3/statuses/search": + case "/rest/api/3/statuses/search": _ = json.NewEncoder(w).Encode(map[string]interface{}{ "startAt": 0, "maxResults": 100, "total": 0, "values": []map[string]interface{}{}, }) From f4826c7674fc8d52add44cc6a561b05a9fdfd506 Mon Sep 17 00:00:00 2001 From: Javier David Carnelli Date: Wed, 26 Aug 2026 00:31:27 -0300 Subject: [PATCH 5/8] fix: relocate ticket-schema resume when project window shifts A project deleted or made inaccessible mid-sync can shift the pinned project window without the stashed resume index going out of bounds, silently skipping a project and misapplying its cached statuses to whatever project now sits at that index. Stash the project key alongside the index and relocate by key on resume instead of trusting position alone. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/tickets.go | 66 +++++++++---- pkg/connector/tickets_test.go | 176 ++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 16 deletions(-) diff --git a/pkg/connector/tickets.go b/pkg/connector/tickets.go index d4406009..7dfa3136 100644 --- a/pkg/connector/tickets.go +++ b/pkg/connector/tickets.go @@ -46,8 +46,10 @@ type ticketSchemaPageToken struct { ProjectOffset int `json:"project_offset,omitempty"` ProjectPageSize int `json:"project_page_size,omitempty"` ProjectIndexInPage int `json:"project_index,omitempty"` - IssueTypeIndex int `json:"issue_type_index,omitempty"` - Statuses []*v2.TicketStatus `json:"statuses,omitempty"` + // ProjectIndexKey is the project.Key at ProjectIndexInPage when this token was stashed, used to detect a resume window that shifted rather than just shrank. + ProjectIndexKey string `json:"project_index_key,omitempty"` + IssueTypeIndex int `json:"issue_type_index,omitempty"` + Statuses []*v2.TicketStatus `json:"statuses,omitempty"` } // issueTypePairsPerPage returns the per-call cap on issue-type pairs processed by ListTicketSchemas. @@ -448,22 +450,53 @@ func (j *Jira) ListTicketSchemas(ctx context.Context, p *pagination.Token) ([]*v issueTypeIndex := tok.IssueTypeIndex resumedProjectIndex := tok.ProjectIndexInPage - if projectIndex > 0 && projectIndex >= len(projects) { - l.Debug( - "ticket schema project window shrank on resume, advancing to next window", - zap.Int("project_offset", tok.ProjectOffset), - zap.Int("stashed_project_index", projectIndex), - zap.Int("returned_project_count", len(projects)), - ) + if projectIndex > 0 { + outOfBounds := projectIndex >= len(projects) + // Tokens without a stashed key predate ProjectIndexKey, so fall back to trusting the index. + identityMismatch := tok.ProjectIndexKey != "" && !outOfBounds && projects[projectIndex].Key != tok.ProjectIndexKey + + if outOfBounds || identityMismatch { + relocated := -1 + if tok.ProjectIndexKey != "" { + for i, proj := range projects { + if proj.Key == tok.ProjectIndexKey { + relocated = i + break + } + } + } - nextPageToken, err := marshalTicketSchemaPageToken(ticketSchemaPageToken{ - ProjectOffset: tok.ProjectOffset + len(projects), - }) - if err != nil { - return nil, "", nil, err - } + if relocated == -1 { + l.Debug( + "ticket schema project window shrank on resume, advancing to next window", + zap.Int("project_offset", tok.ProjectOffset), + zap.Int("stashed_project_index", projectIndex), + zap.String("stashed_project_key", tok.ProjectIndexKey), + zap.Int("returned_project_count", len(projects)), + ) + + nextPageToken, err := marshalTicketSchemaPageToken(ticketSchemaPageToken{ + ProjectOffset: tok.ProjectOffset + len(projects), + }) + if err != nil { + return nil, "", nil, err + } - return ret, nextPageToken, nil, nil + return ret, nextPageToken, nil, nil + } + + // The stashed project shifted index rather than leaving the window, so relocate it instead of misapplying its statuses to whatever project is now at the stale index. + l.Debug( + "ticket schema project window shifted on resume, relocating stashed project", + zap.Int("project_offset", tok.ProjectOffset), + zap.Int("stashed_project_index", projectIndex), + zap.Int("resolved_project_index", relocated), + zap.String("project_key", tok.ProjectIndexKey), + ) + + projectIndex = relocated + resumedProjectIndex = relocated + } } nextPageTokenAt := func(projectIndex, issueTypeIndex int, statuses []*v2.TicketStatus) (string, error) { @@ -473,6 +506,7 @@ func (j *Jira) ListTicketSchemas(ctx context.Context, p *pagination.Token) ([]*v // token refetches the identical window instead of one sized off its own p.Size. ProjectPageSize: projectPageSize, ProjectIndexInPage: projectIndex, + ProjectIndexKey: projects[projectIndex].Key, IssueTypeIndex: issueTypeIndex, Statuses: statuses, }) diff --git a/pkg/connector/tickets_test.go b/pkg/connector/tickets_test.go index f9b91009..2b9fed78 100644 --- a/pkg/connector/tickets_test.go +++ b/pkg/connector/tickets_test.go @@ -666,6 +666,182 @@ func TestListTicketSchemas_GuardsShrinkingResumeWindow(t *testing.T) { } } +func TestListTicketSchemas_RelocatesResumeAfterFrontOfWindowShift(t *testing.T) { + p1 := buildManyIssueTypesProject("P1", "1", 2) + p2 := buildManyIssueTypesProject("P2", "2", 3) + p2.statuses = []map[string]interface{}{{"id": "1", "name": "P2Done"}} + p3 := buildManyIssueTypesProject("P3", "3", 2) + p3.statuses = []map[string]interface{}{{"id": "2", "name": "P3Done"}} + + full := []ticketProjectFixture{p1, p2, p3} + shifted := []ticketProjectFixture{p2, p3} // P1 vanishes from the FRONT before the resume + + byKeyOrID := func(projects []ticketProjectFixture, idOrKey string) *ticketProjectFixture { + for i := range projects { + if projects[i].key == idOrKey || projects[i].id == idOrKey { + return &projects[i] + } + } + return nil + } + + // active only changes inside the project/search branch, so it stays fixed for the rest of that call. + active := full + searchCalls := 0 + statusesCalls := map[string]int{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/rest/api/2/project/search": + if searchCalls > 0 { + active = shifted + } + searchCalls++ + startAt, _ := strconv.Atoi(r.URL.Query().Get("startAt")) + end := len(active) + if startAt > end { + startAt = end + } + page := active[startAt:end] + + values := make([]map[string]interface{}, 0, len(page)) + for _, p := range page { + issueTypes := make([]map[string]interface{}, 0, len(p.issueTypes)) + for _, it := range p.issueTypes { + issueTypes = append(issueTypes, map[string]interface{}{ + "id": it.id, "name": it.name, "subtask": it.subtask, + }) + } + values = append(values, map[string]interface{}{ + "id": p.id, "key": p.key, "name": p.name, "issueTypes": issueTypes, + }) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "startAt": startAt, "maxResults": len(page), "total": len(active), "values": values, + }) + + case "/rest/api/3/statuses/search": + projectID := r.URL.Query().Get("projectId") + p := byKeyOrID(active, projectID) + if p == nil { + t.Errorf("unexpected projectId in statuses request: %s", projectID) + w.WriteHeader(http.StatusNotFound) + return + } + statusesCalls[p.key]++ + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "startAt": 0, "maxResults": 100, "total": len(p.statuses), "values": p.statuses, + }) + + default: + var projectIDOrKey, issueTypeID string + if n, _ := fmt.Sscanf(r.URL.Path, "/rest/api/2/issue/createmeta/%s", &projectIDOrKey); n == 1 { + parts := splitLast(projectIDOrKey, "/issuetypes/") + projectIDOrKey, issueTypeID = parts[0], parts[1] + } + p := byKeyOrID(active, projectIDOrKey) + if p == nil { + t.Errorf("unexpected create-meta request for project %s", projectIDOrKey) + w.WriteHeader(http.StatusNotFound) + return + } + var it *ticketIssueType + for i := range p.issueTypes { + if p.issueTypes[i].id == issueTypeID { + it = &p.issueTypes[i] + break + } + } + if it == nil { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "startAt": 0, "maxResults": 100, "total": len(it.fields), "fields": it.fields, + }) + } + })) + defer srv.Close() + + j := newTestJira(t, srv.URL) + j.maxIssueTypePairsPerPage = 3 // P1's 2 pairs + P2's first pair, stashing mid-P2 (index 1, issue type 1) + ctx := ctxzap.ToContext(context.Background(), zap.NewNop()) + + // First call: full 3-project window; the cap hits mid-P2, stashing P2's index and statuses. + first, nextToken, _, err := j.ListTicketSchemas(ctx, &pagination.Token{Size: 3}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(first) != 3 { // P1 x2 + P2's first issue type + t.Fatalf("expected 3 schemas (P1 x2, P2 x1), got %d", len(first)) + } + if nextToken == "" { + t.Fatal("expected a next page token (P2 remainder / P3 still pending)") + } + if statusesCalls["P2"] != 1 { + t.Fatalf("expected exactly 1 statuses call for P2 on the first call, got %d", statusesCalls["P2"]) + } + + // Resume: P1 disappears from the front of the window, shifting P2 into the index the token stashed for P3, so the fix must relocate by key instead of trusting the stale index. + core, logs := observer.New(zap.DebugLevel) + debugCtx := ctxzap.ToContext(context.Background(), zap.New(core)) + + second, nextToken2, _, err := j.ListTicketSchemas(debugCtx, &pagination.Token{Size: 3, Token: nextToken}) + if err != nil { + t.Fatalf("unexpected error on resume: %v", err) + } + // P2 finishes and the leftover cap budget starts P3 too; what matters is each schema carries the right project's statuses. + if len(second) != 3 { + t.Fatalf("expected 3 schemas (P2's remainder x2, P3's first x1), got %d", len(second)) + } + for _, s := range second[:2] { + if len(s.Statuses) != 1 || s.Statuses[0].DisplayName != "P2Done" { + t.Fatalf("schema %s: expected P2's stashed statuses, got %v", s.Id, s.Statuses) + } + } + if len(second[2].Statuses) != 1 || second[2].Statuses[0].DisplayName != "P3Done" { + t.Fatalf("schema %s: expected P3's statuses, got %v", second[2].Id, second[2].Statuses) + } + if statusesCalls["P2"] != 1 { + t.Errorf("expected P2's statuses to be reused from the stash, not refetched; got %d calls", statusesCalls["P2"]) + } + if statusesCalls["P3"] != 1 { + t.Errorf("expected exactly 1 fresh statuses call for P3, got %d", statusesCalls["P3"]) + } + + foundRelocate := false + for _, entry := range logs.All() { + if entry.Message == "ticket schema project window shifted on resume, relocating stashed project" { + foundRelocate = true + } + } + if !foundRelocate { + t.Error("expected a Debug log for the relocated resume position") + } + + if nextToken2 == "" { + t.Fatal("expected pagination to continue to P3's remaining issue type") + } + + // Final call: P3's remaining issue type must still be synced, not skipped or double-counted. + third, nextToken3, _, err := j.ListTicketSchemas(ctx, &pagination.Token{Size: 3, Token: nextToken2}) + if err != nil { + t.Fatalf("unexpected error on final call: %v", err) + } + if len(third) != 1 { + t.Fatalf("expected 1 remaining schema from P3, got %d", len(third)) + } + for _, s := range third { + if len(s.Statuses) != 1 || s.Statuses[0].DisplayName != "P3Done" { + t.Fatalf("schema %s: expected P3's statuses, got %v", s.Id, s.Statuses) + } + } + if nextToken3 != "" { + t.Fatalf("expected pagination to terminate, got next token %q", nextToken3) + } +} + func TestListTicketSchemas_ResumesMidProjectNotFromZero(t *testing.T) { projects := []ticketProjectFixture{buildManyIssueTypesProject("MID", "1", 5)} srv := newTicketSchemaServer(t, projects, 50, nil) From b58e001fef8519e40e4a6b3270f551e0cd5e93b9 Mon Sep 17 00:00:00 2001 From: Javier David Carnelli Date: Wed, 26 Aug 2026 01:42:57 -0300 Subject: [PATCH 6/8] fix: check resume identity at index 0 and use project ID, not key The identity check only ran for projectIndex > 0, so a stashed resume at index 0 (cap hit mid-first-project) never verified the window still matched, letting stale statuses apply to a different project. Also switch the stashed identifier from project.Key (user-renamable) to project.ID (immutable) to avoid false mismatches on rename. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/tickets.go | 31 ++++---- pkg/connector/tickets_test.go | 140 ++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 16 deletions(-) diff --git a/pkg/connector/tickets.go b/pkg/connector/tickets.go index 7dfa3136..3bd9b4db 100644 --- a/pkg/connector/tickets.go +++ b/pkg/connector/tickets.go @@ -43,13 +43,13 @@ var projectScopedCustomFieldIDs = map[string]bool{ // Statuses carries the current project's statuses across a resume so a project split across // multiple calls by pairCap doesn't refetch them for every remaining page. type ticketSchemaPageToken struct { - ProjectOffset int `json:"project_offset,omitempty"` - ProjectPageSize int `json:"project_page_size,omitempty"` - ProjectIndexInPage int `json:"project_index,omitempty"` - // ProjectIndexKey is the project.Key at ProjectIndexInPage when this token was stashed, used to detect a resume window that shifted rather than just shrank. - ProjectIndexKey string `json:"project_index_key,omitempty"` - IssueTypeIndex int `json:"issue_type_index,omitempty"` - Statuses []*v2.TicketStatus `json:"statuses,omitempty"` + ProjectOffset int `json:"project_offset,omitempty"` + ProjectPageSize int `json:"project_page_size,omitempty"` + ProjectIndexInPage int `json:"project_index,omitempty"` + // ProjectIndexID is project.ID at ProjectIndexInPage when this token was stashed, used to detect a resume window that shifted rather than just shrank. + ProjectIndexID string `json:"project_index_id,omitempty"` + IssueTypeIndex int `json:"issue_type_index,omitempty"` + Statuses []*v2.TicketStatus `json:"statuses,omitempty"` } // issueTypePairsPerPage returns the per-call cap on issue-type pairs processed by ListTicketSchemas. @@ -450,16 +450,15 @@ func (j *Jira) ListTicketSchemas(ctx context.Context, p *pagination.Token) ([]*v issueTypeIndex := tok.IssueTypeIndex resumedProjectIndex := tok.ProjectIndexInPage - if projectIndex > 0 { + if projectIndex > 0 || tok.ProjectIndexID != "" { outOfBounds := projectIndex >= len(projects) - // Tokens without a stashed key predate ProjectIndexKey, so fall back to trusting the index. - identityMismatch := tok.ProjectIndexKey != "" && !outOfBounds && projects[projectIndex].Key != tok.ProjectIndexKey + identityMismatch := tok.ProjectIndexID != "" && !outOfBounds && projects[projectIndex].ID != tok.ProjectIndexID if outOfBounds || identityMismatch { relocated := -1 - if tok.ProjectIndexKey != "" { + if tok.ProjectIndexID != "" { for i, proj := range projects { - if proj.Key == tok.ProjectIndexKey { + if proj.ID == tok.ProjectIndexID { relocated = i break } @@ -471,7 +470,7 @@ func (j *Jira) ListTicketSchemas(ctx context.Context, p *pagination.Token) ([]*v "ticket schema project window shrank on resume, advancing to next window", zap.Int("project_offset", tok.ProjectOffset), zap.Int("stashed_project_index", projectIndex), - zap.String("stashed_project_key", tok.ProjectIndexKey), + zap.String("stashed_project_id", tok.ProjectIndexID), zap.Int("returned_project_count", len(projects)), ) @@ -485,13 +484,13 @@ func (j *Jira) ListTicketSchemas(ctx context.Context, p *pagination.Token) ([]*v return ret, nextPageToken, nil, nil } - // The stashed project shifted index rather than leaving the window, so relocate it instead of misapplying its statuses to whatever project is now at the stale index. + // Relocate rather than trusting the stale index, which may now point at a different project. l.Debug( "ticket schema project window shifted on resume, relocating stashed project", zap.Int("project_offset", tok.ProjectOffset), zap.Int("stashed_project_index", projectIndex), zap.Int("resolved_project_index", relocated), - zap.String("project_key", tok.ProjectIndexKey), + zap.String("project_id", tok.ProjectIndexID), ) projectIndex = relocated @@ -506,7 +505,7 @@ func (j *Jira) ListTicketSchemas(ctx context.Context, p *pagination.Token) ([]*v // token refetches the identical window instead of one sized off its own p.Size. ProjectPageSize: projectPageSize, ProjectIndexInPage: projectIndex, - ProjectIndexKey: projects[projectIndex].Key, + ProjectIndexID: projects[projectIndex].ID, IssueTypeIndex: issueTypeIndex, Statuses: statuses, }) diff --git a/pkg/connector/tickets_test.go b/pkg/connector/tickets_test.go index 2b9fed78..cdfb9ffd 100644 --- a/pkg/connector/tickets_test.go +++ b/pkg/connector/tickets_test.go @@ -842,6 +842,146 @@ func TestListTicketSchemas_RelocatesResumeAfterFrontOfWindowShift(t *testing.T) } } +func TestListTicketSchemas_DetectsIndexZeroIdentityMismatch(t *testing.T) { + p1 := buildManyIssueTypesProject("P1", "1", 3) + p1.statuses = []map[string]interface{}{{"id": "1", "name": "P1Done"}} + p2 := buildManyIssueTypesProject("P2", "2", 2) + p3 := buildManyIssueTypesProject("P3", "3", 2) + + full := []ticketProjectFixture{p1, p2, p3} + shrunk := []ticketProjectFixture{p2, p3} // P1 vanishes; P2 backfills into index 0 + + byKeyOrID := func(projects []ticketProjectFixture, idOrKey string) *ticketProjectFixture { + for i := range projects { + if projects[i].key == idOrKey || projects[i].id == idOrKey { + return &projects[i] + } + } + return nil + } + + active := full + searchCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/rest/api/2/project/search": + if searchCalls > 0 { + active = shrunk + } + searchCalls++ + + startAt, _ := strconv.Atoi(r.URL.Query().Get("startAt")) + maxResults, _ := strconv.Atoi(r.URL.Query().Get("maxResults")) + end := startAt + maxResults + if end > len(active) { + end = len(active) + } + if startAt > len(active) { + startAt = len(active) + } + page := active[startAt:end] + + values := make([]map[string]interface{}, 0, len(page)) + for _, p := range page { + issueTypes := make([]map[string]interface{}, 0, len(p.issueTypes)) + for _, it := range p.issueTypes { + issueTypes = append(issueTypes, map[string]interface{}{ + "id": it.id, "name": it.name, "subtask": it.subtask, + }) + } + values = append(values, map[string]interface{}{ + "id": p.id, "key": p.key, "name": p.name, "issueTypes": issueTypes, + }) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "startAt": startAt, "maxResults": maxResults, "total": len(active), "values": values, + }) + + case "/rest/api/3/statuses/search": + projectID := r.URL.Query().Get("projectId") + p := byKeyOrID(active, projectID) + if p == nil { + t.Errorf("unexpected projectId in statuses request: %s", projectID) + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "startAt": 0, "maxResults": 100, "total": len(p.statuses), "values": p.statuses, + }) + + default: + var projectIDOrKey, issueTypeID string + if n, _ := fmt.Sscanf(r.URL.Path, "/rest/api/2/issue/createmeta/%s", &projectIDOrKey); n == 1 { + parts := splitLast(projectIDOrKey, "/issuetypes/") + projectIDOrKey, issueTypeID = parts[0], parts[1] + } + p := byKeyOrID(active, projectIDOrKey) + if p == nil { + t.Errorf("unexpected create-meta request for project %s", projectIDOrKey) + w.WriteHeader(http.StatusNotFound) + return + } + var it *ticketIssueType + for i := range p.issueTypes { + if p.issueTypes[i].id == issueTypeID { + it = &p.issueTypes[i] + break + } + } + if it == nil { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "startAt": 0, "maxResults": 100, "total": len(it.fields), "fields": it.fields, + }) + } + })) + defer srv.Close() + + j := newTestJira(t, srv.URL) + j.maxIssueTypePairsPerPage = 1 // stash mid-P1, at project index 0 + ctx := ctxzap.ToContext(context.Background(), zap.NewNop()) + + first, nextToken, _, err := j.ListTicketSchemas(ctx, &pagination.Token{Size: 2}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(first) != 1 { + t.Fatalf("expected 1 schema (P1's first issue type), got %d", len(first)) + } + if nextToken == "" { + t.Fatal("expected a next page token") + } + + core, logs := observer.New(zap.DebugLevel) + debugCtx := ctxzap.ToContext(context.Background(), zap.New(core)) + + // P1 vanished entirely, so the fix must detect the index-0 mismatch and advance rather + // than applying P1's stashed statuses to whatever project now backfills index 0. + second, _, _, err := j.ListTicketSchemas(debugCtx, &pagination.Token{Size: 2, Token: nextToken}) + if err != nil { + t.Fatalf("unexpected error on resume: %v", err) + } + for _, s := range second { + if len(s.Statuses) == 1 && s.Statuses[0].DisplayName == "P1Done" { + t.Fatalf("schema %s: P1's statuses leaked onto a different project: %v", s.Id, s.Statuses) + } + } + + foundShrink := false + for _, entry := range logs.All() { + if entry.Message == "ticket schema project window shrank on resume, advancing to next window" { + foundShrink = true + } + } + if !foundShrink { + t.Error("expected a Debug log detecting the index-0 identity mismatch") + } +} + func TestListTicketSchemas_ResumesMidProjectNotFromZero(t *testing.T) { projects := []ticketProjectFixture{buildManyIssueTypesProject("MID", "1", 5)} srv := newTicketSchemaServer(t, projects, 50, nil) From daa4be9dab572f5d6fd7f470619ba7638db50309 Mon Sep 17 00:00:00 2001 From: Javier David Carnelli Date: Wed, 26 Aug 2026 01:59:22 -0300 Subject: [PATCH 7/8] test: improve coverage of IndexZero tests --- pkg/connector/tickets_test.go | 187 ++++++++++++++++++++++++++++++++-- 1 file changed, 179 insertions(+), 8 deletions(-) diff --git a/pkg/connector/tickets_test.go b/pkg/connector/tickets_test.go index cdfb9ffd..fdaded10 100644 --- a/pkg/connector/tickets_test.go +++ b/pkg/connector/tickets_test.go @@ -959,15 +959,38 @@ func TestListTicketSchemas_DetectsIndexZeroIdentityMismatch(t *testing.T) { core, logs := observer.New(zap.DebugLevel) debugCtx := ctxzap.ToContext(context.Background(), zap.New(core)) - // P1 vanished entirely, so the fix must detect the index-0 mismatch and advance rather - // than applying P1's stashed statuses to whatever project now backfills index 0. - second, _, _, err := j.ListTicketSchemas(debugCtx, &pagination.Token{Size: 2, Token: nextToken}) - if err != nil { - t.Fatalf("unexpected error on resume: %v", err) + // P1 vanished entirely, so the fix must detect the index-0 mismatch instead of + // applying P1's stashed statuses to whatever project now backfills index 0. + all := append([]*v2.TicketSchema{}, first...) + token := nextToken + calls := 1 + for token != "" { + calls++ + if calls > 10 { + t.Fatalf("pagination did not terminate within 10 calls (ghost-state / infinite loop)") + } + schemas, next, _, err := j.ListTicketSchemas(debugCtx, &pagination.Token{Size: 2, Token: token}) + if err != nil { + t.Fatalf("call %d: unexpected error: %v", calls, err) + } + for _, s := range schemas { + if len(s.Statuses) == 1 && s.Statuses[0].DisplayName == "P1Done" { + t.Fatalf("schema %s: P1's statuses leaked onto a different project: %v", s.Id, s.Statuses) + } + } + all = append(all, schemas...) + token = next + } + + // P2 and P3 were never touched by the deletion; a resumed sync must still emit their + // schemas rather than silently dropping the rest of the window once P1 is gone. + seen := map[string]bool{} + for _, s := range all { + seen[s.Id] = true } - for _, s := range second { - if len(s.Statuses) == 1 && s.Statuses[0].DisplayName == "P1Done" { - t.Fatalf("schema %s: P1's statuses leaked onto a different project: %v", s.Id, s.Statuses) + for _, want := range []string{"P2:1", "P2:2", "P3:1", "P3:2"} { + if !seen[want] { + t.Errorf("expected schema %s to be synced, got %v", want, all) } } @@ -982,6 +1005,154 @@ func TestListTicketSchemas_DetectsIndexZeroIdentityMismatch(t *testing.T) { } } +// TestListTicketSchemas_DropsRestOfWindowWhenDeletedProjectNotFound covers the same class of +// bug as TestListTicketSchemas_DetectsIndexZeroIdentityMismatch, but at a non-zero stashed +// index: the stashed project is deleted outright (not shifted elsewhere in the window), so the +// relocation scan can't find it anywhere, yet other untouched projects still sit in the window. +func TestListTicketSchemas_DropsRestOfWindowWhenDeletedProjectNotFound(t *testing.T) { + p0 := buildManyIssueTypesProject("P0", "0", 1) + p1 := buildManyIssueTypesProject("P1", "1", 3) + p1.statuses = []map[string]interface{}{{"id": "1", "name": "P1Done"}} + p2 := buildManyIssueTypesProject("P2", "2", 2) + p3 := buildManyIssueTypesProject("P3", "3", 2) + + full := []ticketProjectFixture{p0, p1, p2, p3} + shrunk := []ticketProjectFixture{p0, p2, p3} // P1 vanishes outright; P2/P3 stay untouched + + byKeyOrID := func(projects []ticketProjectFixture, idOrKey string) *ticketProjectFixture { + for i := range projects { + if projects[i].key == idOrKey || projects[i].id == idOrKey { + return &projects[i] + } + } + return nil + } + + active := full + searchCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/rest/api/2/project/search": + if searchCalls > 0 { + active = shrunk + } + searchCalls++ + + startAt, _ := strconv.Atoi(r.URL.Query().Get("startAt")) + maxResults, _ := strconv.Atoi(r.URL.Query().Get("maxResults")) + end := startAt + maxResults + if end > len(active) { + end = len(active) + } + if startAt > len(active) { + startAt = len(active) + } + page := active[startAt:end] + + values := make([]map[string]interface{}, 0, len(page)) + for _, p := range page { + issueTypes := make([]map[string]interface{}, 0, len(p.issueTypes)) + for _, it := range p.issueTypes { + issueTypes = append(issueTypes, map[string]interface{}{ + "id": it.id, "name": it.name, "subtask": it.subtask, + }) + } + values = append(values, map[string]interface{}{ + "id": p.id, "key": p.key, "name": p.name, "issueTypes": issueTypes, + }) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "startAt": startAt, "maxResults": maxResults, "total": len(active), "values": values, + }) + + case "/rest/api/3/statuses/search": + projectID := r.URL.Query().Get("projectId") + p := byKeyOrID(active, projectID) + if p == nil { + t.Errorf("unexpected projectId in statuses request: %s", projectID) + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "startAt": 0, "maxResults": 100, "total": len(p.statuses), "values": p.statuses, + }) + + default: + var projectIDOrKey, issueTypeID string + if n, _ := fmt.Sscanf(r.URL.Path, "/rest/api/2/issue/createmeta/%s", &projectIDOrKey); n == 1 { + parts := splitLast(projectIDOrKey, "/issuetypes/") + projectIDOrKey, issueTypeID = parts[0], parts[1] + } + p := byKeyOrID(active, projectIDOrKey) + if p == nil { + t.Errorf("unexpected create-meta request for project %s", projectIDOrKey) + w.WriteHeader(http.StatusNotFound) + return + } + var it *ticketIssueType + for i := range p.issueTypes { + if p.issueTypes[i].id == issueTypeID { + it = &p.issueTypes[i] + break + } + } + if it == nil { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "startAt": 0, "maxResults": 100, "total": len(it.fields), "fields": it.fields, + }) + } + })) + defer srv.Close() + + j := newTestJira(t, srv.URL) + j.maxIssueTypePairsPerPage = 2 // P0's 1 pair + P1's first pair, stashing mid-P1 (index 1) + ctx := ctxzap.ToContext(context.Background(), zap.NewNop()) + + first, nextToken, _, err := j.ListTicketSchemas(ctx, &pagination.Token{Size: 4}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(first) != 2 { // P0's schema + P1's first issue type + t.Fatalf("expected 2 schemas, got %d", len(first)) + } + if nextToken == "" { + t.Fatal("expected a next page token") + } + + all := append([]*v2.TicketSchema{}, first...) + token := nextToken + calls := 1 + for token != "" { + calls++ + if calls > 10 { + t.Fatalf("pagination did not terminate within 10 calls (ghost-state / infinite loop)") + } + schemas, next, _, err := j.ListTicketSchemas(ctx, &pagination.Token{Size: 4, Token: token}) + if err != nil { + t.Fatalf("call %d: unexpected error: %v", calls, err) + } + all = append(all, schemas...) + token = next + } + + // P2 and P3 were never touched by P1's deletion; a resumed sync must still emit their + // schemas rather than silently dropping the rest of the window once P1 is gone. + seen := map[string]bool{} + for _, s := range all { + seen[s.Id] = true + } + for _, want := range []string{"P2:1", "P2:2", "P3:1", "P3:2"} { + if !seen[want] { + t.Errorf("expected schema %s to be synced, got %v", want, all) + } + } +} + func TestListTicketSchemas_ResumesMidProjectNotFromZero(t *testing.T) { projects := []ticketProjectFixture{buildManyIssueTypesProject("MID", "1", 5)} srv := newTicketSchemaServer(t, projects, 50, nil) From 934938f7c3aea5980701e03ae7a011846a94f9ae Mon Sep 17 00:00:00 2001 From: Javier David Carnelli Date: Wed, 26 Aug 2026 02:00:58 -0300 Subject: [PATCH 8/8] fix: don't drop the rest of the window when a stashed project is gone The relocation guard treated "no match found" as "nothing left to process" and always skipped past the whole window, even when other, untouched projects still sat at or after the stashed index. Only take that shortcut when the index is actually out of bounds; otherwise resume the window from the current index as if visiting it fresh. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/tickets.go | 42 ++++++++++++++++++++++++----------- pkg/connector/tickets_test.go | 8 +++---- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/pkg/connector/tickets.go b/pkg/connector/tickets.go index 3bd9b4db..26c6dd1c 100644 --- a/pkg/connector/tickets.go +++ b/pkg/connector/tickets.go @@ -465,7 +465,22 @@ func (j *Jira) ListTicketSchemas(ctx context.Context, p *pagination.Token) ([]*v } } - if relocated == -1 { + switch { + case relocated >= 0: + // Relocate rather than trusting the stale index, which may now point at a different project. + l.Debug( + "ticket schema project window shifted on resume, relocating stashed project", + zap.Int("project_offset", tok.ProjectOffset), + zap.Int("stashed_project_index", projectIndex), + zap.Int("resolved_project_index", relocated), + zap.String("project_id", tok.ProjectIndexID), + ) + + projectIndex = relocated + resumedProjectIndex = relocated + + case outOfBounds: + // Nothing left in this window to process; advance past it. l.Debug( "ticket schema project window shrank on resume, advancing to next window", zap.Int("project_offset", tok.ProjectOffset), @@ -482,19 +497,20 @@ func (j *Jira) ListTicketSchemas(ctx context.Context, p *pagination.Token) ([]*v } return ret, nextPageToken, nil, nil - } - // Relocate rather than trusting the stale index, which may now point at a different project. - l.Debug( - "ticket schema project window shifted on resume, relocating stashed project", - zap.Int("project_offset", tok.ProjectOffset), - zap.Int("stashed_project_index", projectIndex), - zap.Int("resolved_project_index", relocated), - zap.String("project_id", tok.ProjectIndexID), - ) - - projectIndex = relocated - resumedProjectIndex = relocated + default: + // The stashed project is gone, but other projects still sit at/after this index; + // process the window from here as fresh rather than skipping the rest of it. + l.Debug( + "ticket schema stashed project not found on resume, resuming window from current index", + zap.Int("project_offset", tok.ProjectOffset), + zap.Int("stashed_project_index", projectIndex), + zap.String("stashed_project_id", tok.ProjectIndexID), + ) + + issueTypeIndex = 0 + resumedProjectIndex = -1 + } } } diff --git a/pkg/connector/tickets_test.go b/pkg/connector/tickets_test.go index fdaded10..740c9248 100644 --- a/pkg/connector/tickets_test.go +++ b/pkg/connector/tickets_test.go @@ -994,13 +994,13 @@ func TestListTicketSchemas_DetectsIndexZeroIdentityMismatch(t *testing.T) { } } - foundShrink := false + foundMismatch := false for _, entry := range logs.All() { - if entry.Message == "ticket schema project window shrank on resume, advancing to next window" { - foundShrink = true + if entry.Message == "ticket schema stashed project not found on resume, resuming window from current index" { + foundMismatch = true } } - if !foundShrink { + if !foundMismatch { t.Error("expected a Debug log detecting the index-0 identity mismatch") } }