From caef20fec8dee3d5c7df6ff23600938da381a4b7 Mon Sep 17 00:00:00 2001 From: BingZ Date: Fri, 28 Aug 2026 17:25:48 +0800 Subject: [PATCH] fix(gitlab): paginate TaskPage issues beyond 50 (#13538) Co-authored-by: Neil --- src/main/gitlab/gitlab-preload-args.ts | 6 +- src/main/gitlab/gl-utils.test.ts | 22 ++++++ src/main/gitlab/gl-utils.ts | 9 ++- src/main/gitlab/glab-api-response.ts | 12 +++ src/main/gitlab/issues.test.ts | 79 ++++++++++++++++--- src/main/gitlab/issues.ts | 29 +++++-- src/main/gitlab/merge-request-list.ts | 15 +--- src/main/ipc/gitlab-issue-handlers.ts | 11 ++- src/main/ipc/gitlab.test.ts | 11 ++- src/main/runtime/orca-runtime.test.ts | 43 +++++++--- src/main/runtime/orca-runtime.ts | 16 +++- src/main/runtime/rpc/methods/gitlab.test.ts | 17 +++- src/main/runtime/rpc/methods/gitlab.ts | 6 +- src/preload/api/gitlab-api.ts | 8 +- src/preload/gitlab.ts | 3 +- src/renderer/src/components/TaskPage.tsx | 25 +++++- .../gitlab/gitlab-issue-pages.test.ts | 79 +++++++++++++++++++ .../task-page/gitlab/gitlab-issue-pages.ts | 41 ++++++++++ .../gitlab/gitlab-work-item-list.tsx | 23 +++++- .../hooks/use-task-page-gitlab-fetch.ts | 50 +++++++++++- .../hooks/use-task-page-gitlab-list-state.ts | 31 +++++++- 21 files changed, 467 insertions(+), 69 deletions(-) create mode 100644 src/renderer/src/components/task-page/gitlab/gitlab-issue-pages.test.ts create mode 100644 src/renderer/src/components/task-page/gitlab/gitlab-issue-pages.ts diff --git a/src/main/gitlab/gitlab-preload-args.ts b/src/main/gitlab/gitlab-preload-args.ts index 483f02f9c11..fa2844f6659 100644 --- a/src/main/gitlab/gitlab-preload-args.ts +++ b/src/main/gitlab/gitlab-preload-args.ts @@ -48,14 +48,18 @@ export function normalizeGitLabIssueListArgs(args: { state?: unknown assignee?: unknown limit?: unknown + page?: unknown }): { state: GitLabIssueListState assignee: '@me' | undefined limit: number + page: number } { return { state: normalizeGitLabIssueListState(args.state), assignee: normalizeGitLabIssueAssignee(args.assignee), - limit: normalizeGitLabPositiveInteger(args.limit, 20, 100) + limit: normalizeGitLabPositiveInteger(args.limit, 20, 100), + // Why: GitLab is 1-based; TaskPage maps 0-based UI pages onto this (#13357). + page: normalizeGitLabPositiveInteger(args.page, 1, 10_000) } } diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index f0c294ce93e..6387455483b 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -24,6 +24,7 @@ import { GITLAB_ADMISSION_TIMEOUT_MS, getIssueProjectRef, parseGlabJsonList, + parseGlabPaginationHeader, isMissingJobLogError, getGlabKnownHosts, getProjectRef, @@ -907,3 +908,24 @@ describe('getGlabKnownHosts', () => { unregisterSshGitProvider(connectionId) }) }) + +describe('parseGlabPaginationHeader', () => { + it('reads a usable header value', () => { + expect(parseGlabPaginationHeader('25', 1)).toBe(25) + expect(parseGlabPaginationHeader(' 9 ', 1)).toBe(9) + }) + + it('returns undefined for an absent or unparseable header', () => { + expect(parseGlabPaginationHeader(undefined, 0)).toBeUndefined() + expect(parseGlabPaginationHeader('', 0)).toBeUndefined() + expect(parseGlabPaginationHeader('abc', 0)).toBeUndefined() + }) + + // Why: the minimum is what lets issues.ts tell "x-total: 0" (derive one page) apart from an + // absent header (probe for a next page), and what makes x-total-pages: 0 fall through. + it('rejects values below the minimum', () => { + expect(parseGlabPaginationHeader('0', 1)).toBeUndefined() + expect(parseGlabPaginationHeader('0', 0)).toBe(0) + expect(parseGlabPaginationHeader('-3', 0)).toBeUndefined() + }) +}) diff --git a/src/main/gitlab/gl-utils.ts b/src/main/gitlab/gl-utils.ts index 7e93228165d..f31c47105de 100644 --- a/src/main/gitlab/gl-utils.ts +++ b/src/main/gitlab/gl-utils.ts @@ -29,7 +29,12 @@ export type { ProjectRef, ResolvedIssueSource } from './gitlab-project-ref-resolution' -export { parseGlabApiResponse, parseGlabJsonList, type GlabApiResponse } from './glab-api-response' +export { + parseGlabApiResponse, + parseGlabJsonList, + parseGlabPaginationHeader, + type GlabApiResponse +} from './glab-api-response' const MAX_CONCURRENT = 4 export const GITLAB_ADMISSION_TIMEOUT_MS = 30_000 @@ -86,7 +91,7 @@ export function release(): void { export async function glabApiWithHeaders( args: string[], - options?: { cwd?: string } + options?: Parameters[1] ): Promise { const { stdout } = await glabExecFileAsync(['api', '-i', ...args], options) return parseGlabApiResponse(stdout) diff --git a/src/main/gitlab/glab-api-response.ts b/src/main/gitlab/glab-api-response.ts index fc7c946aad1..03e1bb9a14e 100644 --- a/src/main/gitlab/glab-api-response.ts +++ b/src/main/gitlab/glab-api-response.ts @@ -25,6 +25,18 @@ export function parseGlabApiResponse(stdout: string): GlabApiResponse { return { body, headers } } +/** A GitLab pagination header, or undefined when absent, unparseable, or below `minimum`. */ +export function parseGlabPaginationHeader( + value: string | undefined, + minimum: number +): number | undefined { + if (!value) { + return undefined + } + const parsed = Number.parseInt(value, 10) + return Number.isFinite(parsed) && parsed >= minimum ? parsed : undefined +} + /** A non-list body carrying no GitLab error text — opaque data, so there is nothing to classify. */ export class GlabNonListResponseError extends Error {} diff --git a/src/main/gitlab/issues.test.ts b/src/main/gitlab/issues.test.ts index c86ee077fdf..9baa049e65c 100644 --- a/src/main/gitlab/issues.test.ts +++ b/src/main/gitlab/issues.test.ts @@ -3,6 +3,7 @@ import type * as GlUtils from './gl-utils' const { glabExecFileAsyncMock, + glabApiWithHeadersMock, getIssueProjectRefMock, resolveIssueSourceMock, getGlabKnownHostsMock, @@ -10,6 +11,7 @@ const { releaseMock } = vi.hoisted(() => ({ glabExecFileAsyncMock: vi.fn(), + glabApiWithHeadersMock: vi.fn(), getIssueProjectRefMock: vi.fn(), resolveIssueSourceMock: vi.fn(), getGlabKnownHostsMock: vi.fn(), @@ -22,6 +24,7 @@ vi.mock('./gl-utils', async () => { return { ...actual, glabExecFileAsync: glabExecFileAsyncMock, + glabApiWithHeaders: glabApiWithHeadersMock, getIssueProjectRef: getIssueProjectRefMock, resolveIssueSource: resolveIssueSourceMock, getGlabKnownHosts: getGlabKnownHostsMock, @@ -37,6 +40,7 @@ import { listAssignableUsers, listLabels } from './project-label-and-member-look describe('gitlab issue operations', () => { beforeEach(() => { glabExecFileAsyncMock.mockReset() + glabApiWithHeadersMock.mockReset() getIssueProjectRefMock.mockReset() resolveIssueSourceMock.mockReset() getGlabKnownHostsMock.mockReset() @@ -86,7 +90,6 @@ describe('gitlab issue operations', () => { labels: [] }) }) - .mockResolvedValueOnce({ stdout: '[]' }) .mockResolvedValueOnce({ stdout: JSON.stringify({ iid: 924, @@ -104,6 +107,7 @@ describe('gitlab issue operations', () => { }) .mockResolvedValueOnce({ stdout: 'bug\nfrontend\n' }) .mockResolvedValueOnce({ stdout: '{"id":1,"username":"octo","avatar_url":""}\n' }) + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) await getIssue('/repo-root', 923, null, localGitOptions) await listIssues('/repo-root', 5, undefined, 'opened', undefined, null, localGitOptions) @@ -137,6 +141,12 @@ describe('gitlab issue operations', () => { expect(glabExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( true ) + expect(glabApiWithHeadersMock).toHaveBeenCalledWith( + [ + 'projects/stablyai%2Forca/issues?page=1&per_page=5&order_by=updated_at&sort=desc&state=opened' + ], + { cwd: '/repo-root', ...localGitOptions } + ) }) it('encodes nested group paths', async () => { @@ -157,22 +167,65 @@ describe('gitlab issue operations', () => { it('lists issues with state=opened ordering', async () => { getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + glabApiWithHeadersMock.mockResolvedValueOnce({ + body: '[]', + headers: { 'x-total': '123', 'x-total-pages': '25' } + }) - await expect(listIssues('/repo-root', 5)).resolves.toEqual({ items: [] }) + await expect(listIssues('/repo-root', 5)).resolves.toEqual({ items: [], totalPages: 25 }) - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + expect(glabApiWithHeadersMock).toHaveBeenCalledWith( [ - 'api', - 'projects/stablyai%2Forca/issues?per_page=5&order_by=updated_at&sort=desc&state=opened' + 'projects/stablyai%2Forca/issues?page=1&per_page=5&order_by=updated_at&sort=desc&state=opened' ], { cwd: '/repo-root' } ) }) + it('forwards an explicit page into the issues API path after localGitOptions', async () => { + getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) + + await expect( + listIssues('/repo-root', 50, undefined, 'opened', undefined, null, {}, 3) + ).resolves.toMatchObject({ totalPages: 3 }) + + expect(glabApiWithHeadersMock).toHaveBeenCalledWith( + [ + 'projects/stablyai%2Forca/issues?page=3&per_page=50&order_by=updated_at&sort=desc&state=opened' + ], + { cwd: '/repo-root' } + ) + }) + + it('derives total pages from x-total when x-total-pages is unavailable', async () => { + getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: { 'x-total': '11' } }) + + await expect(listIssues('/repo-root', 5)).resolves.toMatchObject({ totalPages: 3 }) + }) + + it('keeps a next-page probe when a proxy strips pagination headers', async () => { + getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) + glabApiWithHeadersMock.mockResolvedValueOnce({ + body: JSON.stringify( + Array.from({ length: 5 }, (_, index) => ({ + iid: index + 1, + title: `Issue ${index + 1}`, + state: 'opened', + web_url: `https://gitlab.com/stablyai/orca/-/issues/${index + 1}`, + labels: [] + })) + ), + headers: {} + }) + + await expect(listIssues('/repo-root', 5)).resolves.toMatchObject({ totalPages: 2 }) + }) + it('surfaces a permission_denied error instead of collapsing to empty', async () => { getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden')) + glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden')) const result = await listIssues('/repo-root', 5) @@ -182,8 +235,9 @@ describe('gitlab issue operations', () => { it('reports the body instead of ".map is not a function" when the API returns a non-array', async () => { getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ data: [], total: 0 }) + glabApiWithHeadersMock.mockResolvedValueOnce({ + body: JSON.stringify({ data: [], total: 0 }), + headers: {} }) const result = await listIssues('/repo-root', 5) @@ -196,8 +250,9 @@ describe('gitlab issue operations', () => { it('reports a GitLab error envelope by its own message', async () => { getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ message: '403 Forbidden' }) + glabApiWithHeadersMock.mockResolvedValueOnce({ + body: JSON.stringify({ message: '403 Forbidden' }), + headers: {} }) const result = await listIssues('/repo-root', 5) @@ -230,7 +285,7 @@ describe('gitlab issue operations', () => { it('threads connectionId into getGlabKnownHosts for listIssues', async () => { getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) await listIssues('/repo-root', 5, undefined, 'opened', undefined, 'conn-7') diff --git a/src/main/gitlab/issues.ts b/src/main/gitlab/issues.ts index bfe19ef411b..41d9228bcae 100644 --- a/src/main/gitlab/issues.ts +++ b/src/main/gitlab/issues.ts @@ -3,13 +3,15 @@ import type { GitLabCommentResult, GitLabIssueInfo, MRComment } from '../../shar import type { IssueSourcePreference } from '../../shared/repo-types' import { mapGitLabIssueInfo } from './mappers' // prettier-ignore -import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListFetchError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, parseGlabJsonList, type LocalGitExecOptions, type ProjectRef } from './gl-utils' +import { glabApiWithHeaders, glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListFetchError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, parseGlabJsonList, parseGlabPaginationHeader, type LocalGitExecOptions, type ProjectRef } from './gl-utils' import { encodedProject } from './project-path-encoding' // Why: parallel to GitHub's IssueListResult — distinguishes a successful- // empty listing from a failed fetch. export type IssueListResult = { items: GitLabIssueInfo[] + /** 0 when the listing failed — the caller keeps its current pager instead of collapsing it. */ + totalPages: number error?: ClassifiedError } @@ -74,8 +76,11 @@ export async function listIssues( state: IssueListState = 'opened', assignee?: string, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + page = 1 ): Promise { + const currentPage = Number.isFinite(page) ? Math.max(1, Math.trunc(page)) : 1 + const perPage = Number.isFinite(limit) ? Math.max(1, Math.trunc(limit)) : 20 const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const { source: projectRef } = await resolveIssueSource( repoPath, @@ -94,6 +99,7 @@ export async function listIssues( if (!projectRef) { return { items: [], + totalPages: 0, error: { type: 'not_found', message: 'Could not resolve a GitLab project for this repository.' @@ -104,24 +110,33 @@ export async function listIssues( try { const stateParam = state === 'all' ? '' : `&state=${state}` const scopeParam = assignee === '@me' ? '&scope=assigned_to_me' : '' - const { stdout } = await glabExecFileAsync( + const { body, headers } = await glabApiWithHeaders( [ - 'api', ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}${scopeParam}` + `projects/${encodedProject(projectRef.path)}/issues?page=${currentPage}&per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}${scopeParam}` ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) - const data = parseGlabJsonList>(stdout) + const data = parseGlabJsonList>(body) + const headerTotalCount = parseGlabPaginationHeader(headers['x-total'], 0) + // Why: a proxy can strip both headers, so a full page advertises one more to probe (#13357); + // TaskPage retreats if that probe comes back empty. + const probedTotalPages = data.length < perPage ? currentPage : currentPage + 1 // Why: GitLab's project issues endpoint returns true issues only // (MRs are a separate endpoint), so no equivalent of GitHub's // pull_request filter is needed here. return { - items: data.map((d) => mapGitLabIssueInfo(d as Parameters[0])) + items: data.map((d) => mapGitLabIssueInfo(d as Parameters[0])), + totalPages: + parseGlabPaginationHeader(headers['x-total-pages'], 1) ?? + (headerTotalCount === undefined + ? probedTotalPages + : Math.max(1, Math.ceil(headerTotalCount / perPage))) } } catch (err) { return { items: [], + totalPages: 0, error: classifyListFetchError(err) } } finally { diff --git a/src/main/gitlab/merge-request-list.ts b/src/main/gitlab/merge-request-list.ts index b8a2e7148bf..82587413b1a 100644 --- a/src/main/gitlab/merge-request-list.ts +++ b/src/main/gitlab/merge-request-list.ts @@ -10,6 +10,7 @@ import { glabRepoExecOptions, glabExecFileAsync, parseGlabJsonList, + parseGlabPaginationHeader, release, resolveIssueSource, type LocalGitExecOptions @@ -130,11 +131,11 @@ export async function listMergeRequests( items: data.map((d) => mapMRToWorkItem(d, repoId, projectRef)), page, perPage, - totalCount: parseHeaderInt(headers['x-total'], 0), + totalCount: parseGlabPaginationHeader(headers['x-total'], 0) ?? 0, // Why: GitLab may omit x-total-pages for 'all' or large per_page; fall back to ceil(total/perPage). totalPages: - parseHeaderInt(headers['x-total-pages'], 0) || - Math.max(1, Math.ceil(parseHeaderInt(headers['x-total'], 0) / perPage)) + parseGlabPaginationHeader(headers['x-total-pages'], 1) ?? + Math.max(1, Math.ceil((parseGlabPaginationHeader(headers['x-total'], 0) ?? 0) / perPage)) } } catch (err) { return { @@ -150,14 +151,6 @@ export async function listMergeRequests( } } -function parseHeaderInt(value: string | undefined, fallback: number): number { - if (!value) { - return fallback - } - const parsed = Number.parseInt(value, 10) - return Number.isFinite(parsed) ? parsed : fallback -} - /** * Fetch a work item (MR or issue) by explicit project ref + iid + type. * Used by the paste-URL flow, where the URL determines the project directly. diff --git a/src/main/ipc/gitlab-issue-handlers.ts b/src/main/ipc/gitlab-issue-handlers.ts index 7ae8edf6ceb..d9d5ac2c4a9 100644 --- a/src/main/ipc/gitlab-issue-handlers.ts +++ b/src/main/ipc/gitlab-issue-handlers.ts @@ -44,10 +44,12 @@ export function registerGitLabIssueHandlers(store: Store): void { state?: 'opened' | 'closed' | 'all' assignee?: string limit?: number + page?: number } ) => { const repo = assertRegisteredRepo(args, store) const limit = normalizeGitLabPositiveInteger(args.limit, 20, 100) + const page = normalizeGitLabPositiveInteger(args.page, 1, 10_000) const state = normalizeGitLabIssueListState(args.state) const assignee = normalizeGitLabIssueAssignee(args.assignee) const result = await listIssues( @@ -57,7 +59,8 @@ export function registerGitLabIssueHandlers(store: Store): void { state, assignee, repoConnectionId(repo), - ...localGitOptionArgs(store, repo) + localGitOptionArgs(store, repo)[0] ?? {}, + page ) // Why: Tasks page expects GitLabWorkItem[] so it can share row // rendering with MRs. Map IssueInfo → WorkItem here so the renderer @@ -74,7 +77,11 @@ export function registerGitLabIssueHandlers(store: Store): void { author: issue.author ?? null, repoId: repo.id })) - return { items: workItems, ...(result.error ? { error: result.error } : {}) } + return { + items: workItems, + totalPages: result.totalPages, + ...(result.error ? { error: result.error } : {}) + } } ) diff --git a/src/main/ipc/gitlab.test.ts b/src/main/ipc/gitlab.test.ts index 87a298e56aa..d4bd1235aad 100644 --- a/src/main/ipc/gitlab.test.ts +++ b/src/main/ipc/gitlab.test.ts @@ -353,7 +353,7 @@ describe('GitLab IPC handlers', () => { ] listMergeRequestsMock.mockResolvedValue({ items: [] }) listWorkItemsMock.mockResolvedValue({ items: [] }) - listIssuesMock.mockResolvedValue({ items: [] }) + listIssuesMock.mockResolvedValue({ items: [], totalPages: 3 }) getIssueMock.mockResolvedValue(null) createIssueMock.mockResolvedValue({ ok: true, number: 1, url: 'https://gitlab.example/1' }) updateIssueMock.mockResolvedValue({ ok: true }) @@ -385,10 +385,11 @@ describe('GitLab IPC handlers', () => { page: 1, perPage: 20 }) - await ipcHandlers.get('gitlab:listIssues')?.(null, { + const issueListResult = await ipcHandlers.get('gitlab:listIssues')?.(null, { repoPath: '/local/orca', state: 'opened', - limit: 20 + limit: 20, + page: 3 }) await ipcHandlers.get('gitlab:issue')?.(null, { repoPath: '/local/orca', number: 7 }) await ipcHandlers.get('gitlab:createIssue')?.(null, { @@ -430,6 +431,7 @@ describe('GitLab IPC handlers', () => { null, localGitOptions ) + expect(issueListResult).toMatchObject({ totalPages: 3 }) expect(listWorkItemsMock).toHaveBeenCalledWith( '/local/orca', 'opened', @@ -447,7 +449,8 @@ describe('GitLab IPC handlers', () => { 'opened', undefined, null, - localGitOptions + localGitOptions, + 3 ) expect(getIssueMock).toHaveBeenCalledWith('/local/orca', 7, null, localGitOptions) expect(createIssueMock).toHaveBeenCalledWith( diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 4f02816f1c5..be6bfe922ad 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -47634,7 +47634,8 @@ describe('OrcaRuntimeService', () => { updatedAt: '2026-05-22T00:00:00Z', author: 'alex' } - ] + ], + totalPages: 3 }) listGitLabTodosMock.mockResolvedValue([]) listGitLabLabelsMock.mockResolvedValue(['bug', 'frontend']) @@ -47679,7 +47680,7 @@ describe('OrcaRuntimeService', () => { await runtime.listGitLabRepoMRs(TEST_REPO_ID, 'closed', 2, 25, 'ambiguous selector') await runtime.listGitLabRepoWorkItems(TEST_REPO_ID, 'closed', 2, 25, 'ambiguous selector') - const issues = await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'opened', '@me', 50) + const issues = await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'opened', '@me', 50, 3) await runtime.listGitLabRepoTodos(TEST_REPO_ID) await runtime.listGitLabRepoLabels(TEST_REPO_ID) await runtime.createGitLabRepoIssue(TEST_REPO_ID, 'New issue', 'Body') @@ -47734,7 +47735,9 @@ describe('OrcaRuntimeService', () => { 'origin', 'opened', '@me', - 'ssh-1' + 'ssh-1', + {}, + 3 ) expect(issues.items).toEqual([ { @@ -47750,6 +47753,7 @@ describe('OrcaRuntimeService', () => { repoId: TEST_REPO_ID } ]) + expect(issues).toMatchObject({ totalPages: 3 }) expect(listGitLabTodosMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') expect(listGitLabLabelsMock).toHaveBeenCalledWith('/remote/repo', 'origin', 'ssh-1') expect(createGitLabIssueMock).toHaveBeenCalledWith( @@ -47920,7 +47924,8 @@ describe('OrcaRuntimeService', () => { 'opened', undefined, null, - localGitOptions + localGitOptions, + 1 ) expect(listGitLabTodosMock).toHaveBeenCalledWith(TEST_REPO_PATH, null, localGitOptions) expect(listGitLabLabelsMock).toHaveBeenCalledWith( @@ -48127,9 +48132,21 @@ describe('OrcaRuntimeService', () => { it('normalizes runtime GitLab issue list arguments like the desktop IPC path', async () => { const runtime = new OrcaRuntimeService(store as never) - await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'closed', 'someone-else' as never, 250.8) - await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'all', '@me', 0.7) - await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'unexpected' as never, '@me', Number.NaN) + await runtime.listGitLabRepoIssues( + TEST_REPO_ID, + 'closed', + 'someone-else' as never, + 250.8, + 20_000 + ) + await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'all', '@me', 0.7, 0) + await runtime.listGitLabRepoIssues( + TEST_REPO_ID, + 'unexpected' as never, + '@me', + Number.NaN, + Number.NaN + ) expect(listGitLabIssuesMock).toHaveBeenNthCalledWith( 1, @@ -48138,7 +48155,9 @@ describe('OrcaRuntimeService', () => { undefined, 'closed', undefined, - null + null, + {}, + 10_000 ) expect(listGitLabIssuesMock).toHaveBeenNthCalledWith( 2, @@ -48147,7 +48166,9 @@ describe('OrcaRuntimeService', () => { undefined, 'all', '@me', - null + null, + {}, + 1 ) expect(listGitLabIssuesMock).toHaveBeenNthCalledWith( 3, @@ -48156,7 +48177,9 @@ describe('OrcaRuntimeService', () => { undefined, 'opened', '@me', - null + null, + {}, + 1 ) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 7bec3f8192c..4f494c2a3b5 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -23078,13 +23078,16 @@ export class OrcaRuntimeService { repoSelector: string, state?: GitLabIssueListState, assignee?: string, - limit?: number + limit?: number, + page?: number ): Promise<{ items: GitLabWorkItem[] + totalPages: number error?: Awaited>['error'] }> { const repo = await this.resolveRepoSelector(repoSelector) - const normalized = normalizeGitLabIssueListArgs({ state, assignee, limit }) + const normalized = normalizeGitLabIssueListArgs({ state, assignee, limit, page }) + // Why: page is after localGitOptions; never spread optional args before it (#13538). const result = await listGitLabIssues( repo.path, normalized.limit, @@ -23092,7 +23095,8 @@ export class OrcaRuntimeService { normalized.state, normalized.assignee, repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) + this.getLocalGitExecutionOptionArgs(repo)[0] ?? {}, + normalized.page ) // Why: web runtime mirrors the desktop preload contract, where GitLab // issue rows share the GitLabWorkItem shape with MRs on TaskPage. @@ -23108,7 +23112,11 @@ export class OrcaRuntimeService { author: issue.author ?? null, repoId: repo.id })) - return { items, ...(result.error ? { error: result.error } : {}) } + return { + items, + totalPages: result.totalPages, + ...(result.error ? { error: result.error } : {}) + } } async listGitLabRepoTodos( diff --git a/src/main/runtime/rpc/methods/gitlab.test.ts b/src/main/runtime/rpc/methods/gitlab.test.ts index fe1ecd113c7..e363752ec34 100644 --- a/src/main/runtime/rpc/methods/gitlab.test.ts +++ b/src/main/runtime/rpc/methods/gitlab.test.ts @@ -64,7 +64,8 @@ describe('gitlab RPC methods', () => { repo: 'id:repo-1', state: 'opened', assignee: '@me', - limit: 50 + limit: 50, + page: 2 }) ) await dispatcher.dispatch( @@ -202,7 +203,7 @@ describe('gitlab RPC methods', () => { 25, 'bug' ) - expect(runtime.listGitLabRepoIssues).toHaveBeenCalledWith('id:repo-1', 'opened', '@me', 50) + expect(runtime.listGitLabRepoIssues).toHaveBeenCalledWith('id:repo-1', 'opened', '@me', 50, 2) expect(runtime.createGitLabRepoIssue).toHaveBeenCalledWith('id:repo-1', 'Fix bug', 'Details') expect(runtime.listGitLabRepoTodos).toHaveBeenCalledWith('id:repo-1') expect(runtime.listGitLabRepoLabels).toHaveBeenCalledWith('id:repo-1') @@ -309,9 +310,17 @@ describe('gitlab RPC methods', () => { 'id:repo-1', 'closed', undefined, - 100 + 100, + 1 + ) + expect(runtime.listGitLabRepoIssues).toHaveBeenNthCalledWith( + 2, + 'id:repo-1', + 'opened', + '@me', + 1, + 1 ) - expect(runtime.listGitLabRepoIssues).toHaveBeenNthCalledWith(2, 'id:repo-1', 'opened', '@me', 1) }) // Regression for #7732: the WS/relay transports close the connection on frames diff --git a/src/main/runtime/rpc/methods/gitlab.ts b/src/main/runtime/rpc/methods/gitlab.ts index 81c298d57bb..3cb1faf8be6 100644 --- a/src/main/runtime/rpc/methods/gitlab.ts +++ b/src/main/runtime/rpc/methods/gitlab.ts @@ -36,7 +36,8 @@ const WorkItemsList = RepoSelector.extend({ const IssuesList = RepoSelector.extend({ state: z.unknown().optional(), assignee: OptionalString, - limit: OptionalFiniteNumber + limit: OptionalFiniteNumber, + page: OptionalFiniteNumber }) const CreateIssue = RepoSelector.extend({ @@ -182,7 +183,8 @@ export const GITLAB_METHODS: RpcMethod[] = [ params.repo, normalized.state, normalized.assignee, - normalized.limit + normalized.limit, + normalized.page ) } }), diff --git a/src/preload/api/gitlab-api.ts b/src/preload/api/gitlab-api.ts index ecde3880083..dd3f0fdc689 100644 --- a/src/preload/api/gitlab-api.ts +++ b/src/preload/api/gitlab-api.ts @@ -67,8 +67,14 @@ export type GitLabApi = { state?: 'opened' | 'closed' | 'all' assignee?: string limit?: number + page?: number } - ) => Promise<{ items: GitLabWorkItem[]; error?: ClassifiedError }> + ) => Promise<{ + items: GitLabWorkItem[] + /** Optional while paired with a host older than issue pagination. */ + totalPages?: number + error?: ClassifiedError + }> createIssue: ( args: GitLabRepoSelectorArgs & { title: string diff --git a/src/preload/gitlab.ts b/src/preload/gitlab.ts index 8fbe1105af8..d6f12367db0 100644 --- a/src/preload/gitlab.ts +++ b/src/preload/gitlab.ts @@ -56,8 +56,9 @@ export const glApi = { state?: 'opened' | 'closed' | 'all' assignee?: string limit?: number + page?: number } - ): Promise<{ items: unknown[]; error?: unknown }> => + ): Promise<{ items: unknown[]; totalPages?: number; error?: unknown }> => ipcRenderer.invoke('gitlab:listIssues', args), createIssue: ( diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 4ef3cc430f6..82e3680b2cf 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -349,6 +349,12 @@ export default function TaskPage(): React.JSX.Element { setGitlabDialogItem, gitlabView, setGitlabView, + gitlabIssuePage, + setGitlabIssuePage, + gitlabIssueTotalPages, + setGitlabIssueTotalPages, + gitlabIssueLoadingTargetPage, + setGitlabIssueLoadingTargetPage, gitlabTodos, setGitlabTodos, gitlabTodosLoading, @@ -356,7 +362,7 @@ export default function TaskPage(): React.JSX.Element { gitlabEmptyState, activeGitlabFilter, displayedGitLabItems - } = useTaskPageGitLabListState({ selectedRepos }) + } = useTaskPageGitLabListState({ taskSource, selectedRepos, selectedReposKey }) const { taskSearchInput, setTaskSearchInput, @@ -1083,9 +1089,13 @@ export default function TaskPage(): React.JSX.Element { selectedRepos, selectedReposKey, primaryRepo, + gitlabIssuePage, setGitlabItems, setGitlabLoading, setGitlabError, + setGitlabIssuePage, + setGitlabIssueTotalPages, + setGitlabIssueLoadingTargetPage, setGitlabTodos, setGitlabTodosLoading }) @@ -2791,7 +2801,18 @@ export default function TaskPage(): React.JSX.Element { displayedGitLabItems, gitlabEmptyState, openGitLabDetailPage, - handleUseGitLabItem + handleUseGitLabItem, + showGitlabIssuePagination: gitlabView === 'issues' && gitlabIssueTotalPages > 1, + gitlabIssuePage, + gitlabIssueTotalPages, + gitlabIssueLoadingTargetPage, + onGitlabIssuePageChange: (page: number) => { + if (page === gitlabIssuePage) { + return + } + setGitlabIssueLoadingTargetPage(page) + setGitlabIssuePage(page) + } } const jiraList: JiraIssueListHostProps = { jiraStatusReady, diff --git a/src/renderer/src/components/task-page/gitlab/gitlab-issue-pages.test.ts b/src/renderer/src/components/task-page/gitlab/gitlab-issue-pages.test.ts new file mode 100644 index 00000000000..d79da251ab8 --- /dev/null +++ b/src/renderer/src/components/task-page/gitlab/gitlab-issue-pages.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' + +import { resolveGitLabIssuePageState } from './gitlab-issue-pages' + +const page = (itemCount: number, totalPages?: number) => ({ + items: Array.from({ length: itemCount }, (_, index) => index), + totalPages +}) + +describe('resolveGitLabIssuePageState', () => { + it('takes the widest pager across selected repos', () => { + expect( + resolveGitLabIssuePageState({ + requestedPage: 1, + errorCount: 0, + results: [page(50, 2), page(50, 7), page(10)] + }) + ).toEqual({ page: 1, totalPages: 7 }) + }) + + it('falls back to a single page when no repo reports a count', () => { + expect( + resolveGitLabIssuePageState({ requestedPage: 0, errorCount: 0, results: [page(3)] }) + ).toEqual({ page: 0, totalPages: 1 }) + }) + + it('ignores a non-finite totalPages instead of poisoning the maximum', () => { + expect( + resolveGitLabIssuePageState({ + requestedPage: 0, + errorCount: 0, + results: [page(1, Number.NaN), page(1, 4)] + }) + ).toEqual({ page: 0, totalPages: 4 }) + }) + + it('lands directly on the last page the host reports instead of walking back', () => { + expect( + resolveGitLabIssuePageState({ requestedPage: 3, errorCount: 0, results: [page(0, 1)] }) + ).toEqual({ page: 0, totalPages: 1 }) + }) + + it('steps back one page when the host still claims more pages than it can fill', () => { + expect( + resolveGitLabIssuePageState({ requestedPage: 3, errorCount: 0, results: [page(0, 5)] }) + ).toEqual({ page: 2, totalPages: 3 }) + }) + + it('retreats when a speculative probe page comes back empty', () => { + expect( + resolveGitLabIssuePageState({ requestedPage: 2, errorCount: 0, results: [page(0)] }) + ).toEqual({ page: 1, totalPages: 2 }) + }) + + it('keeps page 0 selected when the first page is genuinely empty', () => { + expect( + resolveGitLabIssuePageState({ requestedPage: 0, errorCount: 0, results: [page(0)] }) + ).toEqual({ page: 0, totalPages: 1 }) + }) + + it('never sizes the pager below the page that just returned rows', () => { + expect( + resolveGitLabIssuePageState({ requestedPage: 3, errorCount: 0, results: [page(4, 2)] }) + ).toEqual({ page: 3, totalPages: 4 }) + }) + + it('holds the requested page and pager size when every repo failed', () => { + expect(resolveGitLabIssuePageState({ requestedPage: 2, errorCount: 1, results: [] })).toEqual({ + page: 2, + totalPages: null + }) + }) + + it('still sizes the pager when some repos returned rows alongside an error', () => { + expect( + resolveGitLabIssuePageState({ requestedPage: 1, errorCount: 1, results: [page(4, 3)] }) + ).toEqual({ page: 1, totalPages: 3 }) + }) +}) diff --git a/src/renderer/src/components/task-page/gitlab/gitlab-issue-pages.ts b/src/renderer/src/components/task-page/gitlab/gitlab-issue-pages.ts new file mode 100644 index 00000000000..dd895eea1cb --- /dev/null +++ b/src/renderer/src/components/task-page/gitlab/gitlab-issue-pages.ts @@ -0,0 +1,41 @@ +/** Pager state derived from one settled round of per-repo GitLab issue requests. */ +export type GitLabIssuePageState = { + /** 0-based page to show — below the requested one when the requested page overshot the list. */ + page: number + /** null keeps the current pager size: a failed load is not evidence of the end of the list. */ + totalPages: number | null +} + +export function resolveGitLabIssuePageState(args: { + requestedPage: number + errorCount: number + results: readonly { items: readonly unknown[]; totalPages?: number }[] +}): GitLabIssuePageState { + const itemCount = args.results.reduce((total, result) => total + result.items.length, 0) + // null distinguishes "no repo reported a count" from "every repo reported one page". + const reported = args.results.reduce( + (maximum, result) => + typeof result.totalPages === 'number' && Number.isFinite(result.totalPages) + ? Math.max(maximum ?? 1, Math.floor(result.totalPages)) + : maximum, + null + ) + // Why: a proxy that strips x-total makes the host advertise one speculative next page (#13357), + // and issues can close under a deep page — either way an empty, error-free page past the first + // means we overshot. Trust a reported count so we land on the last page in one hop instead of + // re-fetching every page on the way back. + if (args.requestedPage > 0 && itemCount === 0 && args.errorCount === 0) { + const totalPages = + reported === null ? args.requestedPage : Math.min(args.requestedPage, reported) + return { page: totalPages - 1, totalPages } + } + if (itemCount === 0 && args.errorCount > 0) { + return { page: args.requestedPage, totalPages: null } + } + // Why: rows on page N prove at least N+1 pages exist — without the floor a host that + // under-reports (or omits) the count would hide the pager and strand the user on a deep page. + return { + page: args.requestedPage, + totalPages: Math.max(reported ?? 1, args.requestedPage + 1) + } +} diff --git a/src/renderer/src/components/task-page/gitlab/gitlab-work-item-list.tsx b/src/renderer/src/components/task-page/gitlab/gitlab-work-item-list.tsx index c4d569fc991..804e9898e32 100644 --- a/src/renderer/src/components/task-page/gitlab/gitlab-work-item-list.tsx +++ b/src/renderer/src/components/task-page/gitlab/gitlab-work-item-list.tsx @@ -7,6 +7,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import type { RepoBackedTaskEmptyState } from '@/components/task-page-empty-state' import { getIntlLocale, translate } from '@/i18n/i18n' import type { GitLabWorkItem } from '../../../../../shared/gitlab-types' +import { PaginationBar } from '../pagination/pagination-bar' export type GitlabWorkItemListProps = { gitlabError: string | null @@ -16,6 +17,11 @@ export type GitlabWorkItemListProps = { gitlabEmptyState: RepoBackedTaskEmptyState openGitLabDetailPage: (item: GitLabWorkItem) => void handleUseGitLabItem: (item: GitLabWorkItem) => void + showGitlabIssuePagination: boolean + gitlabIssuePage: number + gitlabIssueTotalPages: number + gitlabIssueLoadingTargetPage: number | null + onGitlabIssuePageChange: (page: number) => void } export function GitlabWorkItemList({ @@ -25,7 +31,12 @@ export function GitlabWorkItemList({ displayedGitLabItems, gitlabEmptyState, openGitLabDetailPage, - handleUseGitLabItem + handleUseGitLabItem, + showGitlabIssuePagination, + gitlabIssuePage, + gitlabIssueTotalPages, + gitlabIssueLoadingTargetPage, + onGitlabIssuePageChange }: GitlabWorkItemListProps): React.JSX.Element { return (
@@ -148,6 +159,16 @@ export function GitlabWorkItemList({ ))}
+ {showGitlabIssuePagination ? ( +
+ +
+ ) : null} ) } diff --git a/src/renderer/src/components/task-page/hooks/use-task-page-gitlab-fetch.ts b/src/renderer/src/components/task-page/hooks/use-task-page-gitlab-fetch.ts index 63a6cd34d90..d92537ea2bf 100644 --- a/src/renderer/src/components/task-page/hooks/use-task-page-gitlab-fetch.ts +++ b/src/renderer/src/components/task-page/hooks/use-task-page-gitlab-fetch.ts @@ -4,6 +4,7 @@ import { isGitLabIssueFilter, isGitLabMRFilter } from '@/components/task-page/gitlab/gitlab-task-filters' +import { resolveGitLabIssuePageState } from '@/components/task-page/gitlab/gitlab-issue-pages' import { getTaskPageRepoSourceContext } from '@/components/task-page/source/repo-source-context' import { withGitLabIpcTimeout } from '@/runtime/gitlab-ipc-timeout' import type { GitLabIssueFilter, GitLabTaskFilter } from '@/components/task-page-localized-options' @@ -11,6 +12,8 @@ import type { GitLabTodo, GitLabWorkItem } from '../../../../../shared/gitlab-ty import type { Repo } from '../../../../../shared/repo-types' import type { TaskProvider } from '../../../../../shared/task-providers' +const GITLAB_ISSUE_PAGE_SIZE = 50 + export function useTaskPageGitLabFetch({ taskSource, gitlabView, @@ -19,9 +22,13 @@ export function useTaskPageGitLabFetch({ selectedRepos, selectedReposKey, primaryRepo, + gitlabIssuePage, setGitlabItems, setGitlabLoading, setGitlabError, + setGitlabIssuePage, + setGitlabIssueTotalPages, + setGitlabIssueLoadingTargetPage, setGitlabTodos, setGitlabTodosLoading }: { @@ -32,9 +39,13 @@ export function useTaskPageGitLabFetch({ selectedRepos: readonly Repo[] selectedReposKey: string primaryRepo: Repo | null + gitlabIssuePage: number setGitlabItems: Dispatch> setGitlabLoading: Dispatch> setGitlabError: Dispatch> + setGitlabIssuePage: Dispatch> + setGitlabIssueTotalPages: Dispatch> + setGitlabIssueLoadingTargetPage: Dispatch> setGitlabTodos: Dispatch> setGitlabTodosLoading: Dispatch> }): void { @@ -65,6 +76,9 @@ export function useTaskPageGitLabFetch({ return } let stale = false + // Why: a retreat re-runs this effect immediately; clearing loading in between would flash the + // spinner off and re-enable the pager buttons over rows that are about to be replaced. + let retreating = false setGitlabLoading(true) setGitlabError(null) @@ -79,16 +93,18 @@ export function useTaskPageGitLabFetch({ sourceContext: getTaskPageRepoSourceContext(repo, 'gitlab'), state: 'opened', assignee: isAssignedToMe ? '@me' : undefined, - limit: 50 + limit: GITLAB_ISSUE_PAGE_SIZE, + page: gitlabIssuePage + 1 }) ).then((result) => { const typed = result as { items: GitLabWorkItem[] + totalPages?: number error?: { type?: string; message: string } } // Why: not_found just means the repo isn't a GitLab project (mixed selection); drop it so the list shows no false errors. const error = typed.error?.type === 'not_found' ? undefined : typed.error - return { repoId: repo.id, items: typed.items, error } + return { repoId: repo.id, items: typed.items, totalPages: typed.totalPages, error } }) } : (repo: (typeof eligibleRepos)[0]) => @@ -117,11 +133,13 @@ export function useTaskPageGitLabFetch({ } const merged: GitLabWorkItem[] = [] const errs: string[] = [] + const settled: { items: readonly GitLabWorkItem[]; totalPages?: number }[] = [] for (const r of results) { if (r.status !== 'fulfilled') { errs.push(r.reason instanceof Error ? r.reason.message : String(r.reason)) continue } + settled.push(r.value) for (const item of r.value.items) { merged.push({ ...item, repoId: r.value.repoId }) } @@ -129,6 +147,22 @@ export function useTaskPageGitLabFetch({ errs.push(r.value.error.message) } } + if (gitlabView === 'issues') { + const pager = resolveGitLabIssuePageState({ + requestedPage: gitlabIssuePage, + errorCount: errs.length, + results: settled + }) + if (pager.totalPages !== null) { + setGitlabIssueTotalPages(pager.totalPages) + } + // Why: an overshot page holds nothing worth showing — step back and let the refetch fill the list. + if (pager.page !== gitlabIssuePage) { + retreating = true + setGitlabIssuePage(pager.page) + return + } + } merged.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) setGitlabItems(merged) // Why: only banner when every eligible repo failed; a partial one would hide working rows in a mixed (non-GitLab) selection. @@ -137,15 +171,23 @@ export function useTaskPageGitLabFetch({ } }) .finally(() => { - if (!stale) { + if (!stale && !retreating) { setGitlabLoading(false) + setGitlabIssueLoadingTargetPage(null) } }) return () => { stale = true } // eslint-disable-next-line react-hooks/exhaustive-deps -- selectedReposKey covers every selectedRepos field read above (see its GitHub-scoped-context note); keying off the array ref would re-run on every parent render. - }, [taskSource, gitlabView, activeGitlabFilter, gitlabRefreshNonce, selectedReposKey]) + }, [ + taskSource, + gitlabView, + activeGitlabFilter, + gitlabRefreshNonce, + selectedReposKey, + gitlabIssuePage + ]) // Why: Todos fetch has its own effect — different trigger (no chip filter) and data path (gl.todos is user-scoped, not repo-scoped). useEffect(() => { diff --git a/src/renderer/src/components/task-page/hooks/use-task-page-gitlab-list-state.ts b/src/renderer/src/components/task-page/hooks/use-task-page-gitlab-list-state.ts index 7c04b800039..e3a6bd42ac9 100644 --- a/src/renderer/src/components/task-page/hooks/use-task-page-gitlab-list-state.ts +++ b/src/renderer/src/components/task-page/hooks/use-task-page-gitlab-list-state.ts @@ -6,12 +6,17 @@ import { isGitLabMRFilter } from '@/components/task-page/gitlab/gitlab-task-filters' import type { GitLabIssueFilter, GitLabTaskFilter } from '@/components/task-page-localized-options' +import type { TaskProvider } from '../../../../../shared/task-providers' import type { GitLabTodo, GitLabWorkItem } from '../../../../../shared/gitlab-types' export function useTaskPageGitLabListState({ - selectedRepos + taskSource, + selectedRepos, + selectedReposKey }: { + taskSource: TaskProvider selectedRepos: { length: number } + selectedReposKey: string }) { // ── GitLab task-source state ────────────────────────────────────── // Why: parallel to Linear's slim per-source state — skips workItemsCache and cross-repo aggregation; fetches directly via window.api.gl for the primary repo. @@ -23,6 +28,13 @@ export function useTaskPageGitLabListState({ // Why: separate from gitlabItems so the dialog target survives a list refresh that removes the item from the visible filter (e.g. closing an MR). const [gitlabDialogItem, setGitlabDialogItem] = useState(null) + // Why: Issues paginate (#13357) — 0-based here, mapped onto GitLab's 1-based page at fetch time. + const [gitlabIssuePage, setGitlabIssuePage] = useState(0) + const [gitlabIssueTotalPages, setGitlabIssueTotalPages] = useState(1) + const [gitlabIssueLoadingTargetPage, setGitlabIssueLoadingTargetPage] = useState( + null + ) + // Why: GitLab tab has two sub-views — the project MR/issue list and the user's cross-project Todos (a separate stream). const [gitlabView, setGitlabView] = useState<'issues' | 'mrs' | 'todos'>('mrs') const [gitlabTodos, setGitlabTodos] = useState([]) @@ -49,6 +61,17 @@ export function useTaskPageGitLabListState({ setGitlabFilter('opened') } + // Why: reset the pager before commit, not in an effect, so the fetch effect sees page 0 in the + // same render — an effect would fire one throwaway request for the previous chip's deep page. + const gitlabIssueContextKey = `${taskSource}|${gitlabView}|${activeGitlabFilter}|${selectedReposKey}|${gitlabRefreshNonce}` + const [lastGitlabIssueContextKey, setLastGitlabIssueContextKey] = useState(gitlabIssueContextKey) + if (lastGitlabIssueContextKey !== gitlabIssueContextKey) { + setLastGitlabIssueContextKey(gitlabIssueContextKey) + setGitlabIssuePage(0) + setGitlabIssueTotalPages(1) + setGitlabIssueLoadingTargetPage(null) + } + const displayedGitLabItems = useMemo(() => { if (gitlabView === 'issues') { return gitlabItems.filter((item) => item.type === 'issue') @@ -74,6 +97,12 @@ export function useTaskPageGitLabListState({ setGitlabDialogItem, gitlabView, setGitlabView, + gitlabIssuePage, + setGitlabIssuePage, + gitlabIssueTotalPages, + setGitlabIssueTotalPages, + gitlabIssueLoadingTargetPage, + setGitlabIssueLoadingTargetPage, gitlabTodos, setGitlabTodos, gitlabTodosLoading,