fix(gitlab): paginate TaskPage issues beyond 50 (#13538)

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
BingZ
2026-08-28 02:25:48 -07:00
committed by GitHub
co-authored by Neil
parent 83320bff9e
commit caef20fec8
21 changed files with 467 additions and 69 deletions
+5 -1
View File
@@ -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)
}
}
+22
View File
@@ -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()
})
})
+7 -2
View File
@@ -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<typeof glabExecFileAsync>[1]
): Promise<GlabApiResponse> {
const { stdout } = await glabExecFileAsync(['api', '-i', ...args], options)
return parseGlabApiResponse(stdout)
+12
View File
@@ -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 {}
+67 -12
View File
@@ -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')
+22 -7
View File
@@ -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<IssueListResult> {
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<Record<string, unknown>>(stdout)
const data = parseGlabJsonList<Record<string, unknown>>(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<typeof mapGitLabIssueInfo>[0]))
items: data.map((d) => mapGitLabIssueInfo(d as Parameters<typeof mapGitLabIssueInfo>[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 {
+4 -11
View File
@@ -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.
+9 -2
View File
@@ -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 } : {})
}
}
)
+7 -4
View File
@@ -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(
+33 -10
View File
@@ -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
)
})
+12 -4
View File
@@ -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<ReturnType<typeof listGitLabIssues>>['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(
+13 -4
View File
@@ -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
+4 -2
View File
@@ -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
)
}
}),
+7 -1
View File
@@ -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
+2 -1
View File
@@ -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: (
+23 -2
View File
@@ -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,
@@ -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 })
})
})
@@ -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<number | null>(
(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)
}
}
@@ -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 (
<div className="flex min-h-0 max-h-full flex-col rounded-md border border-t-0 border-border/50 bg-muted/50 overflow-hidden rounded-t-none shadow-sm">
@@ -148,6 +159,16 @@ export function GitlabWorkItemList({
))}
</div>
</div>
{showGitlabIssuePagination ? (
<div className="flex-none border-t border-border/50 bg-muted/50">
<PaginationBar
currentPage={gitlabIssuePage}
totalPages={gitlabIssueTotalPages}
loadingTarget={gitlabIssueLoadingTargetPage}
onPageChange={onGitlabIssuePageChange}
/>
</div>
) : null}
</div>
)
}
@@ -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<SetStateAction<GitLabWorkItem[]>>
setGitlabLoading: Dispatch<SetStateAction<boolean>>
setGitlabError: Dispatch<SetStateAction<string | null>>
setGitlabIssuePage: Dispatch<SetStateAction<number>>
setGitlabIssueTotalPages: Dispatch<SetStateAction<number>>
setGitlabIssueLoadingTargetPage: Dispatch<SetStateAction<number | null>>
setGitlabTodos: Dispatch<SetStateAction<GitLabTodo[]>>
setGitlabTodosLoading: Dispatch<SetStateAction<boolean>>
}): 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(() => {
@@ -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<GitLabWorkItem | null>(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<number | null>(
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<GitLabTodo[]>([])
@@ -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,