mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
fix(gitlab): paginate pipeline jobs and bridges past 100 items (#39046)
* fix(gitlab): paginate pipeline jobs and bridges past 100 items The Checks panel fetched one 100-item page of a pipeline's jobs and bridges, so larger pipelines (and expanded child pipelines) silently lost the rest. Move the header-aware page walker used for labels and members into its own module and use it for pipeline jobs and bridges too. Fixes STA-13623 Co-authored-by: alexcastgonz <82875882+pcbeingused333@users.noreply.github.com> * fix(gitlab): follow every advertised page and keep fetched pages on a later failure Review repairs for #39046: follow advancing x-next-page headers without a page cap (the cap now bounds only guessed pages for missing or unusable headers), keep rows already fetched when a later page fails, and stop bridge paging once the parent job list fails. * fix(gitlab): fail the details read on an incomplete page walk A later-page failure now fails the MR details read, so Checks and the dialog keep the jobs they last showed and report the failure, instead of replacing a complete list with a prefix. Forbidden child pipelines (first page refused) stay tolerated. Paging also stops on empty pages and rejects page numbers past the safe-integer range so it always ends. * fix(gitlab): treat exhausted page guesses as incomplete and translate the dialog load error * fix(gitlab): follow only the consecutive next page and name the dialog load-error key A next-page header that skips ahead or carries trailing text is now treated like a missing header, so no page is skipped. The dialog's load-error message uses an intent-named translation key. --------- Co-authored-by: alexcastgonz <82875882+pcbeingused333@users.noreply.github.com>
This commit is contained in:
co-authored by
alexcastgonz
parent
586ba88b1a
commit
48476a8055
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
glabApiWithHeaders,
|
||||
glabHostnameArgs,
|
||||
glabRepoExecOptions,
|
||||
parseGlabJsonList,
|
||||
type LocalGitExecOptions,
|
||||
type ProjectRef
|
||||
} from './gl-utils'
|
||||
|
||||
// GitLab caps per_page at 100.
|
||||
const PAGE_SIZE = 100
|
||||
// Bound guessed pages when a proxy strips, garbles or stalls next-page headers.
|
||||
const MAX_PROBED_PAGES = 50
|
||||
|
||||
/** A page after the first failed, so the rows read so far are not the whole list. */
|
||||
export class GitLabIncompletePaginationError extends Error {}
|
||||
|
||||
// Older glab lacks NDJSON output and --jq; parse each API page separately.
|
||||
export async function fetchAllGitLabPages<T>(
|
||||
repoPath: string,
|
||||
projectRef: ProjectRef,
|
||||
endpoint: string,
|
||||
connectionId: string | null | undefined,
|
||||
localGitOptions: LocalGitExecOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<T[]> {
|
||||
const items: T[] = []
|
||||
let probedPages = 0
|
||||
let page: number | undefined = 1
|
||||
while (page !== undefined) {
|
||||
signal?.throwIfAborted()
|
||||
let pageItems: T[]
|
||||
let nextPageHeader: string | undefined
|
||||
try {
|
||||
const { body, headers } = await glabApiWithHeaders(
|
||||
[
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`${endpoint}?per_page=${PAGE_SIZE}&page=${page}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
|
||||
)
|
||||
pageItems = parseGlabJsonList<T>(body)
|
||||
nextPageHeader = headers['x-next-page']
|
||||
} catch (error) {
|
||||
// A prefix must not pass for the full list; callers keep what they last showed.
|
||||
if (page > 1) {
|
||||
throw new GitLabIncompletePaginationError(`GitLab list incomplete after page ${page - 1}`, {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
items.push(...pageItems)
|
||||
// Follow only a header naming the next page after a non-empty one; a skip would hide rows.
|
||||
if (pageItems.length > 0 && nextPageHeader?.trim() === String(page + 1)) {
|
||||
page += 1
|
||||
} else if (
|
||||
// GitLab ends with an empty header; otherwise probe after full pages.
|
||||
nextPageHeader === '' ||
|
||||
pageItems.length < PAGE_SIZE
|
||||
) {
|
||||
page = undefined
|
||||
} else if (probedPages >= MAX_PROBED_PAGES) {
|
||||
throw new GitLabIncompletePaginationError(
|
||||
`GitLab list still full after ${page} guessed pages`
|
||||
)
|
||||
} else {
|
||||
probedPages += 1
|
||||
page += 1
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export function parseGlabPaginationHeader(
|
||||
return undefined
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isFinite(parsed) && parsed >= minimum ? parsed : undefined
|
||||
return Number.isSafeInteger(parsed) && parsed >= minimum ? parsed : undefined
|
||||
}
|
||||
|
||||
/** A non-list body carrying no GitLab error text — opaque data, so there is nothing to classify. */
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GlabApiResponse from './glab-api-response'
|
||||
|
||||
const { glabApiWithHeadersMock } = vi.hoisted(() => ({ glabApiWithHeadersMock: vi.fn() }))
|
||||
|
||||
vi.mock('./gl-utils', async () => ({
|
||||
...(await vi.importActual<typeof GlabApiResponse>('./glab-api-response')),
|
||||
glabApiWithHeaders: glabApiWithHeadersMock,
|
||||
// Serves single-request fetches from the same pipeline, so a reverted fix fails on row counts.
|
||||
glabExecFileAsync: async (args: string[]) => ({
|
||||
stdout: (await glabApiWithHeadersMock(args.slice(1))).body
|
||||
}),
|
||||
glabHostnameArgs: vi.fn(() => []),
|
||||
glabRepoExecOptions: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
import { GitLabIncompletePaginationError } from './gitlab-paginated-list'
|
||||
import { fetchPipelineJobs } from './pipeline-job-graph'
|
||||
|
||||
/** Serves `/jobs` and `/bridges` 100 per page with GitLab's x-next-page header. */
|
||||
function servePipeline(
|
||||
jobCount: number,
|
||||
bridgeCount: number,
|
||||
headers: 'gitlab' | 'stripped' | 'skipping' = 'gitlab'
|
||||
): void {
|
||||
glabApiWithHeadersMock.mockImplementation(async (args: string[]) => {
|
||||
const match = /pipelines\/7\/(jobs|bridges)\?per_page=100(?:&page=(\d+))?$/.exec(
|
||||
args.at(-1) ?? ''
|
||||
)
|
||||
if (!match) {
|
||||
throw new Error(`unexpected endpoint ${args.at(-1)}`)
|
||||
}
|
||||
const page = Number(match[2] ?? 1)
|
||||
const total = match[1] === 'jobs' ? jobCount : bridgeCount
|
||||
const start = (page - 1) * 100
|
||||
const items = Array.from({ length: Math.max(0, Math.min(100, total - start)) }, (_, i) =>
|
||||
match[1] === 'jobs'
|
||||
? { id: start + i + 1, name: `job-${start + i}` }
|
||||
: { name: `bridge-${start + i}` }
|
||||
)
|
||||
return {
|
||||
body: JSON.stringify(items),
|
||||
headers:
|
||||
headers === 'stripped'
|
||||
? {}
|
||||
: {
|
||||
'x-next-page':
|
||||
start + 100 < total ? String(page + (headers === 'skipping' ? 3 : 1)) : ''
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('fetchPipelineJobs', () => {
|
||||
beforeEach(() => {
|
||||
glabApiWithHeadersMock.mockReset()
|
||||
})
|
||||
|
||||
it.each([
|
||||
// One request per started 100-item page on each endpoint.
|
||||
{ jobs: 130, bridges: 101, requests: 4 },
|
||||
{ jobs: 1300, bridges: 1010, requests: 24 },
|
||||
{ jobs: 5001, bridges: 0, requests: 52 }
|
||||
])(
|
||||
'collects $jobs jobs and $bridges bridges in $requests requests',
|
||||
async ({ jobs, bridges, requests }) => {
|
||||
servePipeline(jobs, bridges)
|
||||
|
||||
const rows = await fetchPipelineJobs('/repo', { host: 'gitlab.com', path: 'g/p' }, 7)
|
||||
|
||||
expect(rows.filter((row) => row.name.startsWith('job-'))).toHaveLength(jobs)
|
||||
expect(rows.filter((row) => row.name.startsWith('bridge-'))).toHaveLength(bridges)
|
||||
expect(glabApiWithHeadersMock).toHaveBeenCalledTimes(requests)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not skip pages when the next-page header jumps ahead', async () => {
|
||||
servePipeline(250, 0, 'skipping')
|
||||
|
||||
const rows = await fetchPipelineJobs('/repo', { host: 'gitlab.com', path: 'g/p' }, 7)
|
||||
|
||||
expect(rows.filter((row) => row.name.startsWith('job-'))).toHaveLength(250)
|
||||
})
|
||||
|
||||
it('fails rather than return a prefix when guessed pages run out', async () => {
|
||||
// Without headers the walker guesses up to 50 pages past page 1, so 5,101 jobs never finish.
|
||||
servePipeline(5101, 0, 'stripped')
|
||||
|
||||
await expect(
|
||||
fetchPipelineJobs('/repo', { host: 'gitlab.com', path: 'g/p' }, 7)
|
||||
).rejects.toBeInstanceOf(GitLabIncompletePaginationError)
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,11 @@
|
||||
import type { GitLabPipelineJob } from '../../shared/gitlab-types'
|
||||
import { encodedProject } from './project-path-encoding'
|
||||
import { gitLabRunTimes } from './gitlab-run-times'
|
||||
import {
|
||||
glabHostnameArgs,
|
||||
glabRepoExecOptions,
|
||||
glabExecFileAsync,
|
||||
type LocalGitExecOptions,
|
||||
type ProjectRef
|
||||
} from './gl-utils'
|
||||
import { fetchAllGitLabPages, GitLabIncompletePaginationError } from './gitlab-paginated-list'
|
||||
import type { LocalGitExecOptions, ProjectRef } from './gl-utils'
|
||||
|
||||
// Child CI jobs live under bridge pipelines, not the parent pipeline's jobs endpoint.
|
||||
|
||||
const PIPELINE_JOB_PAGE_SIZE = 100
|
||||
// Bound child-pipeline expansion per details load.
|
||||
const MAX_CHILD_PIPELINES_TO_EXPAND = 20
|
||||
// Each fetch launches glab; cap polling fan-out to match the shared CLI admission limit.
|
||||
@@ -75,26 +69,23 @@ function mapBridgeAsJob(raw: GitLabRawBridge, pipelineId: number): GitLabPipelin
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPipelineJobPage(
|
||||
async function fetchPipelineJobList(
|
||||
repoPath: string,
|
||||
projectRef: ProjectRef,
|
||||
pipelineId: number,
|
||||
connectionId: string | null | undefined,
|
||||
localGitOptions: LocalGitExecOptions
|
||||
localGitOptions: LocalGitExecOptions,
|
||||
signal: AbortSignal
|
||||
): Promise<GitLabPipelineJob[]> {
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/jobs?per_page=${PIPELINE_JOB_PAGE_SIZE}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
|
||||
const jobs = await fetchAllGitLabPages<GitLabRawJob>(
|
||||
repoPath,
|
||||
projectRef,
|
||||
`projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/jobs`,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
signal
|
||||
)
|
||||
const data = JSON.parse(stdout) as GitLabRawJob[]
|
||||
if (!Array.isArray(data)) {
|
||||
return []
|
||||
}
|
||||
return data.map((job) => mapPipelineJob(job, pipelineId))
|
||||
return jobs.map((job) => mapPipelineJob(job, pipelineId))
|
||||
}
|
||||
|
||||
async function fetchPipelineBridges(
|
||||
@@ -102,18 +93,17 @@ async function fetchPipelineBridges(
|
||||
projectRef: ProjectRef,
|
||||
pipelineId: number,
|
||||
connectionId: string | null | undefined,
|
||||
localGitOptions: LocalGitExecOptions
|
||||
localGitOptions: LocalGitExecOptions,
|
||||
signal: AbortSignal
|
||||
): Promise<GitLabRawBridge[]> {
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/bridges?per_page=${PIPELINE_JOB_PAGE_SIZE}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
|
||||
return fetchAllGitLabPages<GitLabRawBridge>(
|
||||
repoPath,
|
||||
projectRef,
|
||||
`projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/bridges`,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
signal
|
||||
)
|
||||
const data = JSON.parse(stdout) as GitLabRawBridge[]
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
function childPipelineTarget(
|
||||
@@ -173,11 +163,35 @@ export async function fetchPipelineJobs(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitLabPipelineJob[]> {
|
||||
// Once the graph has failed, stop sibling page walks nobody will read.
|
||||
const graphFailed = new AbortController()
|
||||
const failGraph = (error: unknown): never => {
|
||||
graphFailed.abort()
|
||||
throw error
|
||||
}
|
||||
// Bridges and children tolerate a failed first page (e.g. a forbidden child project), never a partial list.
|
||||
const tolerateUnreadable =
|
||||
<T>(fallback: T) =>
|
||||
(error: unknown): T =>
|
||||
error instanceof GitLabIncompletePaginationError ? failGraph(error) : fallback
|
||||
|
||||
const [parentJobs, bridges] = await Promise.all([
|
||||
fetchPipelineJobPage(repoPath, projectRef, pipelineId, connectionId, localGitOptions),
|
||||
fetchPipelineBridges(repoPath, projectRef, pipelineId, connectionId, localGitOptions).catch(
|
||||
() => [] as GitLabRawBridge[]
|
||||
)
|
||||
fetchPipelineJobList(
|
||||
repoPath,
|
||||
projectRef,
|
||||
pipelineId,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
graphFailed.signal
|
||||
).catch(failGraph),
|
||||
fetchPipelineBridges(
|
||||
repoPath,
|
||||
projectRef,
|
||||
pipelineId,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
graphFailed.signal
|
||||
).catch(tolerateUnreadable([] as GitLabRawBridge[]))
|
||||
])
|
||||
|
||||
const bridgeRows = bridges.map((bridge) => mapBridgeAsJob(bridge, pipelineId))
|
||||
@@ -199,13 +213,14 @@ export async function fetchPipelineJobs(
|
||||
childTargets,
|
||||
MAX_CONCURRENT_CHILD_FETCHES,
|
||||
(target) =>
|
||||
fetchPipelineJobPage(
|
||||
fetchPipelineJobList(
|
||||
repoPath,
|
||||
target.projectRef,
|
||||
target.pipelineId,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
).catch(() => [] as GitLabPipelineJob[])
|
||||
localGitOptions,
|
||||
graphFailed.signal
|
||||
).catch(tolerateUnreadable([] as GitLabPipelineJob[]))
|
||||
)
|
||||
|
||||
// Keep parent/child job order, and add bridge rollups only when no expanded job shares their name.
|
||||
|
||||
@@ -4,22 +4,13 @@ import { mapGitLabUser, type GitLabRawUser } from './gitlab-assignable-user-mapp
|
||||
import {
|
||||
acquire,
|
||||
getGlabKnownHosts,
|
||||
glabApiWithHeaders,
|
||||
glabHostnameArgs,
|
||||
glabRepoExecOptions,
|
||||
parseGlabJsonList,
|
||||
parseGlabPaginationHeader,
|
||||
release,
|
||||
resolveIssueSource,
|
||||
type LocalGitExecOptions,
|
||||
type ProjectRef
|
||||
type LocalGitExecOptions
|
||||
} from './gl-utils'
|
||||
import { fetchAllGitLabPages } from './gitlab-paginated-list'
|
||||
import { encodedProject } from './project-path-encoding'
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
// Bound pagination when a proxy strips or fails to advance next-page headers.
|
||||
const MAX_PAGES = 50
|
||||
|
||||
type GitLabRawLabel = { name?: string | null } | null
|
||||
|
||||
export async function listLabels(
|
||||
@@ -41,7 +32,7 @@ export async function listLabels(
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
const labels = await fetchAllPages<GitLabRawLabel>(
|
||||
const labels = await fetchAllGitLabPages<GitLabRawLabel>(
|
||||
repoPath,
|
||||
projectRef,
|
||||
`projects/${encodedProject(projectRef.path)}/labels`,
|
||||
@@ -76,7 +67,7 @@ export async function listAssignableUsers(
|
||||
await acquire()
|
||||
try {
|
||||
// Include members inherited from parent groups, where assignable project users commonly originate.
|
||||
const members = await fetchAllPages<GitLabRawUser | null>(
|
||||
const members = await fetchAllGitLabPages<GitLabRawUser | null>(
|
||||
repoPath,
|
||||
projectRef,
|
||||
`projects/${encodedProject(projectRef.path)}/members/all`,
|
||||
@@ -90,40 +81,3 @@ export async function listAssignableUsers(
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
// Older glab lacks NDJSON output and --jq; parse each API page separately.
|
||||
async function fetchAllPages<T>(
|
||||
repoPath: string,
|
||||
projectRef: ProjectRef,
|
||||
endpoint: string,
|
||||
connectionId: string | null | undefined,
|
||||
localGitOptions: LocalGitExecOptions
|
||||
): Promise<T[]> {
|
||||
const items: T[] = []
|
||||
let page: number | undefined = 1
|
||||
for (let fetched = 0; page !== undefined && fetched < MAX_PAGES; fetched += 1) {
|
||||
const { body, headers } = await glabApiWithHeaders(
|
||||
[
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`${endpoint}?per_page=${PAGE_SIZE}&page=${page}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
|
||||
)
|
||||
const pageItems = parseGlabJsonList<T>(body)
|
||||
items.push(...pageItems)
|
||||
page = nextPageNumber(headers['x-next-page'], page, pageItems.length)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
function nextPageNumber(
|
||||
nextPageHeader: string | undefined,
|
||||
page: number,
|
||||
pageLength: number
|
||||
): number | undefined {
|
||||
// GitLab ends with an empty header; an absent header may be proxy-stripped, so probe after full pages.
|
||||
if (nextPageHeader === undefined) {
|
||||
return pageLength < PAGE_SIZE ? undefined : page + 1
|
||||
}
|
||||
return parseGlabPaginationHeader(nextPageHeader, page + 1)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GlabApiResponse from './glab-api-response'
|
||||
|
||||
const {
|
||||
glabExecFileAsyncMock,
|
||||
@@ -22,7 +23,12 @@ const {
|
||||
releaseMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./gl-utils', () => ({
|
||||
vi.mock('./gl-utils', async () => ({
|
||||
...(await vi.importActual<typeof GlabApiResponse>('./glab-api-response')),
|
||||
glabApiWithHeaders: async (args: string[], options: unknown) => ({
|
||||
body: (await glabExecFileAsyncMock(['api', ...args], options)).stdout,
|
||||
headers: {}
|
||||
}),
|
||||
acquire: acquireMock,
|
||||
release: releaseMock,
|
||||
getGlabKnownHosts: getGlabKnownHostsMock,
|
||||
@@ -72,7 +78,7 @@ describe('getWorkItemDetails', () => {
|
||||
})
|
||||
}
|
||||
}
|
||||
if (endpoint === 'projects/g%2Fp/pipelines/99/bridges?per_page=100') {
|
||||
if (endpoint === 'projects/g%2Fp/pipelines/99/bridges?per_page=100&page=1') {
|
||||
return {
|
||||
stdout: JSON.stringify(
|
||||
Array.from({ length: BRIDGE_COUNT }, (_, i) => ({
|
||||
@@ -110,4 +116,91 @@ describe('getWorkItemDetails', () => {
|
||||
expect(peakJobPages).toBeLessThanOrEqual(5)
|
||||
expect(details?.pipelineJobs).toHaveLength(BRIDGE_COUNT)
|
||||
})
|
||||
|
||||
// Parent pipeline 99 has 150 jobs and 100 bridges, one to child pipeline 1000, which has 150 jobs.
|
||||
function servePipelineGraph(failing: { endpoint: string; page: number; message: string }): void {
|
||||
glabExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
const endpoint = args.at(-1) as string
|
||||
if (endpoint === 'projects/g%2Fp/merge_requests/12') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
iid: 12,
|
||||
title: 'Graph',
|
||||
state: 'opened',
|
||||
head_pipeline: { id: 99 }
|
||||
})
|
||||
}
|
||||
}
|
||||
const listMatch =
|
||||
/^(projects\/g%2Fp\/pipelines\/\d+\/(?:jobs|bridges))\?per_page=100&page=(\d+)$/.exec(
|
||||
endpoint
|
||||
)
|
||||
if (!listMatch) {
|
||||
return { stdout: endpoint.endsWith('/approvals') ? '{}' : '[]' }
|
||||
}
|
||||
const [, list, pageText] = listMatch
|
||||
const page = Number(pageText)
|
||||
if (list === failing.endpoint && page === failing.page) {
|
||||
throw new Error(failing.message)
|
||||
}
|
||||
if (list.endsWith('/bridges')) {
|
||||
// A full first page makes the walker request page 2.
|
||||
const bridges = list.includes('/99/')
|
||||
? [
|
||||
{ name: 'child', downstream_pipeline: { id: 1000, status: 'success' } },
|
||||
...Array.from({ length: 99 }, (_, i) => ({ name: `trigger-${i}` }))
|
||||
]
|
||||
: []
|
||||
return { stdout: JSON.stringify(page === 1 ? bridges : []) }
|
||||
}
|
||||
const pipelineId = Number(/pipelines\/(\d+)/.exec(list)?.[1])
|
||||
const count = page === 1 ? 100 : page === 2 ? 50 : 0
|
||||
return {
|
||||
stdout: JSON.stringify(
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: pipelineId * 1000 + (page - 1) * 100 + i + 1,
|
||||
name: `job-${pipelineId}-${(page - 1) * 100 + i}`
|
||||
}))
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ endpoint: 'projects/g%2Fp/pipelines/99/jobs', includeReviewDetails: true },
|
||||
{ endpoint: 'projects/g%2Fp/pipelines/99/jobs', includeReviewDetails: false },
|
||||
{ endpoint: 'projects/g%2Fp/pipelines/1000/jobs', includeReviewDetails: false },
|
||||
{ endpoint: 'projects/g%2Fp/pipelines/99/bridges', includeReviewDetails: false }
|
||||
])(
|
||||
'fails the read when $endpoint page 2 fails (includeReviewDetails=$includeReviewDetails)',
|
||||
async ({ endpoint, includeReviewDetails }) => {
|
||||
servePipelineGraph({ endpoint, page: 2, message: '503 upstream unavailable' })
|
||||
|
||||
const details = await getWorkItemDetails(
|
||||
'/repo',
|
||||
12,
|
||||
'mr',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ includeReviewDetails }
|
||||
)
|
||||
|
||||
expect(details).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the graph when a child project forbids its first page', async () => {
|
||||
servePipelineGraph({
|
||||
endpoint: 'projects/g%2Fp/pipelines/1000/jobs',
|
||||
page: 1,
|
||||
message: '403 Forbidden'
|
||||
})
|
||||
|
||||
const details = await getWorkItemDetails('/repo', 12, 'mr')
|
||||
|
||||
expect(details?.pipelineJobs?.filter((job) => job.name.startsWith('job-99-'))).toHaveLength(150)
|
||||
expect(details?.pipelineJobs?.some((job) => job.name === 'child')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import { fetchDiscussions, flattenDiscussions } from './mr-discussion-notes'
|
||||
import { fetchMRFiles } from './mr-file-diffs'
|
||||
import { fetchMRApprovalState, fetchMRReviewers } from './mr-reviewers-and-approvals'
|
||||
import { fetchPipelineJobs } from './pipeline-job-graph'
|
||||
import { GitLabIncompletePaginationError } from './gitlab-paginated-list'
|
||||
import { gitLabRunTimes } from './gitlab-run-times'
|
||||
import {
|
||||
acquire,
|
||||
@@ -163,7 +164,13 @@ async function fetchMRDetails(
|
||||
pipelineId,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
).catch(() => [])
|
||||
).catch((error: unknown) => {
|
||||
// A partial job list must fail the read so Checks and the dialog keep what they showed.
|
||||
if (error instanceof GitLabIncompletePaginationError) {
|
||||
throw error
|
||||
}
|
||||
return []
|
||||
})
|
||||
: undefined
|
||||
// Checks needs pipeline/discussion data without the dialog's optional review enrichment.
|
||||
const includeReviewDetails = options.includeReviewDetails !== false
|
||||
|
||||
@@ -23,7 +23,7 @@ export function GitLabPipelineTab({ item, state, pipelineActions }: Props) {
|
||||
if (item.type !== 'mr') {
|
||||
return null
|
||||
}
|
||||
const { details, expandedJobId, jobTraceById, loading, retryingJobId } = state
|
||||
const { details, error, expandedJobId, jobTraceById, loading, retryingJobId } = state
|
||||
const pipelineCheck = details?.pipeline
|
||||
? gitLabPipelineToPRCheck(
|
||||
details.pipeline,
|
||||
@@ -73,7 +73,7 @@ export function GitLabPipelineTab({ item, state, pipelineActions }: Props) {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : !pipelineCheck ? (
|
||||
) : !pipelineCheck && !(error && !details) ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.GitLabItemDialog.f11e3e7675',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { gitLabApiFor } from '@/runtime/gitlab-owner-api'
|
||||
import { useEffect } from 'react'
|
||||
import type { GitLabWorkItem, GitLabWorkItemDetails } from '../../../../shared/gitlab-types'
|
||||
@@ -28,7 +29,13 @@ export function useGitLabItemDetailsEffect(
|
||||
return
|
||||
}
|
||||
if (!data) {
|
||||
setError('Item not found.')
|
||||
// Main returns null for any failed read, not only a missing item.
|
||||
setError(
|
||||
translate(
|
||||
'auto.components.GitLabItemDialog.itemLoadFailed',
|
||||
'Could not load this item. Refresh to try again.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
setDetails(data as GitLabWorkItemDetails)
|
||||
|
||||
@@ -2320,6 +2320,7 @@
|
||||
"cb55b0390f": "Verwalten",
|
||||
"4f9313984d": "Prüfer",
|
||||
"02cbe2de44": "Pipeline",
|
||||
"itemLoadFailed": "Dieses Element konnte nicht geladen werden. Aktualisiere, um es erneut zu versuchen.",
|
||||
"be3d291837": "Dateien",
|
||||
"c996e2962c": "Unterhaltung",
|
||||
"b3c156dd51": "Aktualisieren",
|
||||
|
||||
@@ -2538,6 +2538,7 @@
|
||||
"cb55b0390f": "Manage",
|
||||
"4f9313984d": "Reviewers",
|
||||
"02cbe2de44": "Pipeline",
|
||||
"itemLoadFailed": "Could not load this item. Refresh to try again.",
|
||||
"be3d291837": "Files",
|
||||
"c996e2962c": "Conversation",
|
||||
"b3c156dd51": "Refresh",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"cb55b0390f": "Gestionar",
|
||||
"4f9313984d": "Revisores",
|
||||
"02cbe2de44": "Pipeline",
|
||||
"itemLoadFailed": "No se pudo cargar este elemento. Actualiza para volver a intentarlo.",
|
||||
"be3d291837": "Archivos",
|
||||
"c996e2962c": "Conversación",
|
||||
"b3c156dd51": "Actualizar",
|
||||
|
||||
@@ -2113,6 +2113,7 @@
|
||||
"cb55b0390f": "Gérer",
|
||||
"4f9313984d": "Reviewers",
|
||||
"02cbe2de44": "Pipeline",
|
||||
"itemLoadFailed": "Impossible de charger cet élément. Actualisez pour réessayer.",
|
||||
"be3d291837": "Fichiers",
|
||||
"c996e2962c": "Conversation",
|
||||
"b3c156dd51": "Actualiser",
|
||||
|
||||
@@ -1859,6 +1859,7 @@
|
||||
"cb55b0390f": "管理",
|
||||
"4f9313984d": "レビュアー",
|
||||
"02cbe2de44": "パイプライン",
|
||||
"itemLoadFailed": "この項目を読み込めませんでした。更新して再試行してください。",
|
||||
"be3d291837": "ファイル",
|
||||
"c996e2962c": "会話",
|
||||
"b3c156dd51": "更新",
|
||||
|
||||
@@ -1859,6 +1859,7 @@
|
||||
"cb55b0390f": "관리",
|
||||
"4f9313984d": "리뷰어",
|
||||
"02cbe2de44": "파이프라인",
|
||||
"itemLoadFailed": "이 항목을 불러올 수 없습니다. 새로 고쳐 다시 시도하세요.",
|
||||
"be3d291837": "파일",
|
||||
"c996e2962c": "대화",
|
||||
"b3c156dd51": "새로고침",
|
||||
|
||||
@@ -17859,6 +17859,7 @@
|
||||
"cb55b0390f": "Gerenciar",
|
||||
"4f9313984d": "Revisores",
|
||||
"02cbe2de44": "Pipeline",
|
||||
"itemLoadFailed": "Não foi possível carregar este item. Atualize para tentar novamente.",
|
||||
"be3d291837": "Arquivos",
|
||||
"c996e2962c": "Conversa",
|
||||
"b3c156dd51": "Atualizar",
|
||||
|
||||
@@ -2376,6 +2376,7 @@
|
||||
"cb55b0390f": "Управление",
|
||||
"4f9313984d": "Рецензенты",
|
||||
"02cbe2de44": "Конвейер",
|
||||
"itemLoadFailed": "Не удалось загрузить этот элемент. Обновите, чтобы повторить попытку.",
|
||||
"be3d291837": "Файлы",
|
||||
"c996e2962c": "Обсуждение",
|
||||
"b3c156dd51": "Обновить",
|
||||
|
||||
@@ -2298,6 +2298,7 @@
|
||||
"cb55b0390f": "Yönet",
|
||||
"4f9313984d": "İnceleyenler",
|
||||
"02cbe2de44": "Pipeline",
|
||||
"itemLoadFailed": "Bu öğe yüklenemedi. Tekrar denemek için yenileyin.",
|
||||
"be3d291837": "Dosyalar",
|
||||
"c996e2962c": "Görüşme",
|
||||
"b3c156dd51": "Yenile",
|
||||
|
||||
@@ -2268,6 +2268,7 @@
|
||||
"cb55b0390f": "管理",
|
||||
"4f9313984d": "審查人",
|
||||
"02cbe2de44": "管線",
|
||||
"itemLoadFailed": "無法載入此項目。請重新整理以再試一次。",
|
||||
"be3d291837": "檔案",
|
||||
"c996e2962c": "對話",
|
||||
"b3c156dd51": "重新整理",
|
||||
|
||||
@@ -1859,6 +1859,7 @@
|
||||
"cb55b0390f": "管理",
|
||||
"4f9313984d": "评审人",
|
||||
"02cbe2de44": "管道",
|
||||
"itemLoadFailed": "无法加载此项目。请刷新后重试。",
|
||||
"be3d291837": "文件",
|
||||
"c996e2962c": "对话",
|
||||
"b3c156dd51": "刷新",
|
||||
|
||||
Reference in New Issue
Block a user