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:
Brennan Benson
2026-11-06 11:46:20 -08:00
committed by GitHub
co-authored by alexcastgonz
parent 586ba88b1a
commit 48476a8055
20 changed files with 349 additions and 96 deletions
+73
View File
@@ -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
}
+1 -1
View File
@@ -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)
})
})
+54 -39
View File
@@ -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)
}
+95 -2
View File
@@ -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)
})
})
+8 -1
View File
@@ -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)
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -1859,6 +1859,7 @@
"cb55b0390f": "管理",
"4f9313984d": "レビュアー",
"02cbe2de44": "パイプライン",
"itemLoadFailed": "この項目を読み込めませんでした。更新して再試行してください。",
"be3d291837": "ファイル",
"c996e2962c": "会話",
"b3c156dd51": "更新",
+1
View File
@@ -1859,6 +1859,7 @@
"cb55b0390f": "관리",
"4f9313984d": "리뷰어",
"02cbe2de44": "파이프라인",
"itemLoadFailed": "이 항목을 불러올 수 없습니다. 새로 고쳐 다시 시도하세요.",
"be3d291837": "파일",
"c996e2962c": "대화",
"b3c156dd51": "새로고침",
+1
View File
@@ -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",
+1
View File
@@ -2376,6 +2376,7 @@
"cb55b0390f": "Управление",
"4f9313984d": "Рецензенты",
"02cbe2de44": "Конвейер",
"itemLoadFailed": "Не удалось загрузить этот элемент. Обновите, чтобы повторить попытку.",
"be3d291837": "Файлы",
"c996e2962c": "Обсуждение",
"b3c156dd51": "Обновить",
+1
View File
@@ -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": "重新整理",
+1
View File
@@ -1859,6 +1859,7 @@
"cb55b0390f": "管理",
"4f9313984d": "评审人",
"02cbe2de44": "管道",
"itemLoadFailed": "无法加载此项目。请刷新后重试。",
"be3d291837": "文件",
"c996e2962c": "对话",
"b3c156dd51": "刷新",