diff --git a/apps/web/src/lib/provider-review/gitlab-authorization.test.ts b/apps/web/src/lib/provider-review/gitlab-authorization.test.ts new file mode 100644 index 0000000000..fff390b10e --- /dev/null +++ b/apps/web/src/lib/provider-review/gitlab-authorization.test.ts @@ -0,0 +1,253 @@ +jest.mock('@/lib/integrations/gitlab-service', () => ({ getGitLabIntegration: jest.fn() })); +jest.mock('@/lib/integrations/platforms/gitlab/credential-broker-client', () => ({ + fetchGitLabCredential: jest.fn(), +})); + +import type { PlatformIntegration } from '@kilocode/db/schema'; +import type { + OwnerIntegrationAuthorization, + RepositoryIdentity, +} from '@kilocode/app-shared/code-review/repository-identity'; +import { getGitLabIntegration } from '@/lib/integrations/gitlab-service'; +import { fetchGitLabCredential } from '@/lib/integrations/platforms/gitlab/credential-broker-client'; +import { authorizeGitLabReview, resolveGitLabReviewProject } from './gitlab-authorization'; + +const instanceUrl = 'https://gitlab.com/GitLab'; +const integrationId = '11111111-1111-4111-8111-111111111111'; +const orgId = '22222222-2222-4222-8222-222222222222'; +const userId = 'oauth/current-user'; +const authorization: OwnerIntegrationAuthorization = { + kind: 'ownerIntegration', + owner: { type: 'user', id: userId }, + integrationId, +}; +const repository: RepositoryIdentity = { + provider: 'gitlab', + instanceUrl, + repositoryId: '123', + fullName: 'Group/Sub/Repo', + defaultBranch: null, +}; +const project = { + id: 123, + path_with_namespace: repository.fullName, + web_url: `${instanceUrl}/${repository.fullName}`, + default_branch: 'release/next', +}; +const broker = jest.mocked(fetchGitLabCredential); +const integrationLookup = jest.mocked(getGitLabIntegration); +let integration: PlatformIntegration; +let providerRequests: string[]; + +beforeEach(() => { + jest.resetAllMocks(); + integration = { + id: integrationId, + platform: 'gitlab', + owned_by_user_id: userId, + owned_by_organization_id: null, + integration_type: 'oauth', + integration_status: 'active', + suspended_at: null, + auth_invalid_at: null, + metadata: { gitlab_instance_url: instanceUrl }, + scopes: ['api'], + platform_account_login: 'stale-name', + } as PlatformIntegration; + integrationLookup.mockImplementation(async (owner, selected) => + selected === integrationId && + (owner.type === 'user' + ? owner.id === integration.owned_by_user_id + : owner.id === integration.owned_by_organization_id) + ? integration + : null + ); + broker.mockImplementation(async (actor, selector) => { + const allowedOwner = + integration.owned_by_organization_id === null + ? actor.organizationId === undefined + : actor.organizationId === integration.owned_by_organization_id; + return actor.userId === userId && allowedOwner && selector.integrationId === integrationId + ? { + status: 'available', + token: selector.credential === 'project-exact' ? 'project-secret' : 'integration-secret', + instanceUrl, + glabIsOAuth2: true, + } + : { status: 'not_connected' }; + }); + providerRequests = []; + global.fetch = jest.fn(async (destination, init) => { + const path = new URL(String(destination)).pathname; + providerRequests.push(path); + if (path.endsWith('/user')) + return Response.json({ + id: new Headers(init?.headers).get('authorization') === 'Bearer project-secret' ? 24 : 9, + username: 'actual-provider-actor', + name: 'Provider Actor', + }); + if (path.endsWith('/projects/123')) return Response.json(project); + return Response.json({}, { status: 404 }); + }); +}); + +it.each(['user', 'org'] as const)( + 'AC4 resolves the exact %s owner and actual provider actor', + async type => { + if (type === 'org') { + integration.owned_by_user_id = null; + integration.owned_by_organization_id = orgId; + } + const selected = { ...authorization, owner: { type, id: type === 'org' ? orgId : userId } }; + const auth = await authorizeGitLabReview({ userId, authorization: selected, instanceUrl }); + const resolved = await resolveGitLabReviewProject(auth, '123', repository); + expect(auth).toMatchObject({ + authorization: selected, + actor: { id: '9', login: 'actual-provider-actor', instanceUrl }, + credentialKind: 'gitlabOAuth', + }); + expect(resolved.repository).toEqual({ ...repository, defaultBranch: 'release/next' }); + expect(resolved.canonicalUrl).toBe(`${instanceUrl}/Group/Sub/Repo`); + expect(JSON.stringify(auth)).not.toContain('secret'); + } +); + +it('AC4 rejects another Personal owner before provider access', async () => { + await expect( + authorizeGitLabReview({ + userId, + authorization: { ...authorization, owner: { type: 'user', id: 'other' } }, + instanceUrl, + }) + ).rejects.toMatchObject({ code: 'forbidden' }); + expect(providerRequests).toEqual([]); +}); +it('AC4 never resolves a Personal integration through organization authorization', async () => { + await expect( + authorizeGitLabReview({ + userId, + authorization: { ...authorization, owner: { type: 'org', id: orgId } }, + instanceUrl, + }) + ).rejects.toMatchObject({ code: 'not_connected' }); + expect(providerRequests).toEqual([]); +}); +it('AC4 rejects a non-member even when an organization integration exists', async () => { + integration.owned_by_user_id = null; + integration.owned_by_organization_id = orgId; + await expect( + authorizeGitLabReview({ + userId: 'non-member', + authorization: { ...authorization, owner: { type: 'org', id: orgId } }, + instanceUrl, + }) + ).rejects.toMatchObject({ code: 'not_connected' }); + expect(providerRequests).toEqual([]); +}); +it.each(['not_connected', 'reconnect_required', 'temporarily_unavailable'] as const)( + 'AC4 preserves broker recovery %s without a provider call', + async status => { + broker.mockResolvedValue({ status }); + await expect( + authorizeGitLabReview({ userId, authorization, instanceUrl }) + ).rejects.toMatchObject({ code: status }); + expect(providerRequests).toEqual([]); + } +); +it.each([ + { integration_status: 'suspended' }, + { auth_invalid_at: '2026-08-29 01:16:12.945+00' }, + { suspended_at: '2026-08-29 01:16:12.945+00' }, +])('AC4 rejects inactive or expired integration state: %j', async change => { + Object.assign(integration, change); + await expect(authorizeGitLabReview({ userId, authorization, instanceUrl })).rejects.toMatchObject( + { code: 'reconnect_required' } + ); + expect(providerRequests).toEqual([]); +}); +it.each(['https://gitlab.com', 'https://gitlab.com/other', 'https://other.example/GitLab'])( + 'AC4 rejects the wrong configured instance %s', + async wrongInstance => { + await expect( + authorizeGitLabReview({ userId, authorization, instanceUrl: wrongInstance }) + ).rejects.toMatchObject({ code: 'forbidden' }); + expect(providerRequests).toEqual([]); + } +); +it.each([{ repositoryId: '124' }, { fullName: 'Other/Sub/Repo' }, { provider: 'github' as const }])( + 'AC4 rejects a changed repository identity: %j', + async change => { + const auth = await authorizeGitLabReview({ userId, authorization, instanceUrl }); + await expect( + resolveGitLabReviewProject(auth, '123', { ...repository, ...change }) + ).rejects.toMatchObject({ code: change.fullName ? 'not_found' : 'forbidden' }); + } +); +it('AC4 labels an explicitly selected project actor without borrowing integration grants', async () => { + const auth = await authorizeGitLabReview({ + userId, + authorization, + instanceUrl, + projectTokenId: '123', + }); + expect(auth).toMatchObject({ + actor: { id: '24' }, + credentialKind: 'gitlabProjectToken', + scopes: null, + }); + await expect(resolveGitLabReviewProject(auth, '123', repository)).resolves.toMatchObject({ + repository: { ...repository, defaultBranch: 'release/next' }, + }); + await expect(resolveGitLabReviewProject(auth, '124')).rejects.toMatchObject({ + code: 'forbidden', + }); + expect(providerRequests).not.toContain('/GitLab/api/v4/projects/124'); +}); +it('AC4 retains PAT and read-only grant identity', async () => { + integration.integration_type = 'pat'; + integration.scopes = ['read_api']; + const auth = await authorizeGitLabReview({ userId, authorization, instanceUrl }); + expect(auth).toMatchObject({ + credentialKind: 'gitlabPat', + scopes: ['read_api'], + actor: { id: '9' }, + }); +}); +it('AC4 maps provider token expiry to reconnect without exposing response text', async () => { + global.fetch = jest.fn(async () => + Response.json({ message: 'integration-secret' }, { status: 401 }) + ); + const failure = await authorizeGitLabReview({ userId, authorization, instanceUrl }).catch( + (error: unknown) => error + ); + expect(failure).toMatchObject({ code: 'reconnect_required' }); + expect(JSON.stringify(failure)).not.toContain('secret'); +}); +it.each(['http://gitlab.com/GitLab', 'https://user:secret@gitlab.com/GitLab', 'not a URL'])( + 'AC4 rejects unsafe requested instances: %s', + async unsafe => { + await expect( + authorizeGitLabReview({ userId, authorization, instanceUrl: unsafe }) + ).rejects.toMatchObject({ code: 'unsafe_url' }); + expect(providerRequests).toEqual([]); + } +); +it('AC4 rejects a changed live project ID and a foreign canonical URL', async () => { + const auth = await authorizeGitLabReview({ userId, authorization, instanceUrl }); + for (const change of [{ id: 124 }, { web_url: 'https://other.example/Group/Sub/Repo' }]) { + global.fetch = jest.fn(async () => Response.json({ ...project, ...change })); + await expect(resolveGitLabReviewProject(auth, '123', repository)).rejects.toMatchObject({ + code: 'not_found', + }); + } +}); +it('AC4 rejects an integration pin instead of choosing another connection', async () => { + await expect( + authorizeGitLabReview({ + userId, + authorization: { ...authorization, integrationId: '33333333-3333-4333-8333-333333333333' }, + instanceUrl, + }) + ).rejects.toMatchObject({ code: 'not_connected' }); + expect(providerRequests).toEqual([]); +}); diff --git a/apps/web/src/lib/provider-review/gitlab-authorization.ts b/apps/web/src/lib/provider-review/gitlab-authorization.ts new file mode 100644 index 0000000000..d0950a5bef --- /dev/null +++ b/apps/web/src/lib/provider-review/gitlab-authorization.ts @@ -0,0 +1,227 @@ +import 'server-only'; + +import { z } from 'zod'; +import type { + OwnerIntegrationAuthorization, + RepositoryIdentity, +} from '@kilocode/app-shared/code-review/repository-identity'; +import type { ReviewActor, ReviewAuthorizationContext } from '@kilocode/app-shared/provider-review'; +import { getGitLabIntegration } from '@/lib/integrations/gitlab-service'; +import { + createGitLabInteractiveClient, + GitLabInteractiveError, +} from '@/lib/integrations/platforms/gitlab/interactive-client'; +import { + buildGitLabUrl, + normalizeGitLabInstanceUrl, +} from '@/lib/integrations/platforms/gitlab/instance-url'; +import type { GitLabCredentialSelector } from '@/lib/integrations/platforms/gitlab/credential-broker-client'; + +export function parseGitLab( + schema: z.ZodType, + value: unknown, + code: 'invalid_request' | 'invalid_response' = 'invalid_response' +): T { + const parsed = schema.safeParse(value); + if (!parsed.success) throw new GitLabInteractiveError(code); + return parsed.data; +} + +export const GitLabUserSchema = z.object({ + id: z.number().int().positive(), + username: z.string().nullable().optional(), + name: z.string().nullable().optional(), + avatar_url: z.string().nullable().optional(), +}); +export function gitLabActor( + value: z.infer, + instanceUrl: string +): ReviewActor { + return { + provider: 'gitlab', + instanceUrl, + id: String(value.id), + login: value.username ?? null, + displayName: value.name ?? null, + avatarUrl: z.url({ protocol: /^https$/ }).safeParse(value.avatar_url).success + ? (value.avatar_url ?? null) + : null, + }; +} + +export const GitLabPathSchema = z + .string() + .min(1) + .refine( + value => + !value.includes('\0') && value.split('/').every(part => part && part !== '.' && part !== '..') + ); + +function normalizeReviewInstance(value?: string): string { + try { + return normalizeGitLabInstanceUrl(value); + } catch { + throw new GitLabInteractiveError('unsafe_url'); + } +} +export const GitLabProjectSchema = z.object({ + id: z.number().int().positive(), + path_with_namespace: GitLabPathSchema, + web_url: z.url(), + default_branch: z.string().nullable().optional(), + merge_method: z.enum(['merge', 'rebase_merge', 'ff']).optional(), + squash_option: z.enum(['never', 'always', 'default_on', 'default_off']).optional(), + only_allow_merge_if_pipeline_succeeds: z.boolean().optional(), + only_allow_merge_if_all_discussions_are_resolved: z.boolean().optional(), + allow_merge_on_skipped_pipeline: z.boolean().optional(), + permissions: z + .object({ + project_access: z.object({ access_level: z.number() }).nullish(), + group_access: z.object({ access_level: z.number() }).nullish(), + }) + .optional(), +}); + +export function gitLabResourceUrl(instanceUrl: string, fullName: string, suffix = ''): string { + const fullPath = parseGitLab(GitLabPathSchema, fullName); + return buildGitLabUrl( + instanceUrl, + `/${fullPath.split('/').map(encodeURIComponent).join('/')}${suffix}` + ); +} + +export async function authorizeGitLabReview(input: { + userId: string; + authorization: OwnerIntegrationAuthorization; + instanceUrl: string; + projectTokenId?: string; +}) { + const authorization = parseGitLab( + z.object({ + kind: z.literal('ownerIntegration'), + integrationId: z.uuid(), + owner: z.discriminatedUnion('type', [ + z.object({ type: z.literal('user'), id: z.string().min(1) }), + z.object({ type: z.literal('org'), id: z.uuid() }), + ]), + }), + input.authorization, + 'invalid_request' + ); + if ( + !input.userId || + (authorization.owner.type === 'user' && authorization.owner.id !== input.userId) + ) + throw new GitLabInteractiveError('forbidden'); + const integration = await getGitLabIntegration(authorization.owner, authorization.integrationId); + if ( + !integration || + integration.platform !== 'gitlab' || + integration.id !== authorization.integrationId || + (authorization.owner.type === 'user' + ? integration.owned_by_user_id !== input.userId || + integration.owned_by_organization_id !== null + : integration.owned_by_organization_id !== authorization.owner.id || + integration.owned_by_user_id !== null) + ) + throw new GitLabInteractiveError('not_connected'); + if ( + integration.integration_status !== 'active' || + integration.suspended_at || + integration.auth_invalid_at + ) + throw new GitLabInteractiveError('reconnect_required'); + const metadata = parseGitLab( + z.object({ + gitlab_instance_url: z.string().optional(), + auth_type: z.enum(['oauth', 'pat']).optional(), + }), + integration.metadata ?? {} + ); + // Old integrations omit the instance and auth_type. Keep the stored integration type and + // default instance until old records/clients disappear and the 30-day ledger window expires. + const instanceUrl = normalizeReviewInstance(metadata.gitlab_instance_url); + if (normalizeReviewInstance(input.instanceUrl) !== instanceUrl) + throw new GitLabInteractiveError('forbidden'); + const authType = parseGitLab( + z.enum(['oauth', 'pat']), + metadata.auth_type ?? integration.integration_type + ); + const selector: GitLabCredentialSelector = + input.projectTokenId === undefined + ? { credential: 'integration', integrationId: integration.id } + : { + credential: 'project-exact', + integrationId: integration.id, + projectId: parseGitLab( + z.string().regex(/^[1-9]\d*$/), + input.projectTokenId, + 'invalid_request' + ), + }; + const credentialActor = { + userId: input.userId, + ...(authorization.owner.type === 'org' ? { organizationId: authorization.owner.id } : {}), + }; + const client = (projectId?: string) => + createGitLabInteractiveClient({ + actor: credentialActor, + selector, + instanceUrl, + scope: projectId === undefined ? { kind: 'discovery' } : { kind: 'project', projectId }, + }); + // The broker checks current membership, blocked users, exact ownership, and credential expiry. + const current = await client(input.projectTokenId).execute(api => api.Users.showCurrentUser()); + const actor = gitLabActor(parseGitLab(GitLabUserSchema, current.data), instanceUrl); + const credentialKind: ReviewAuthorizationContext['credentialKind'] = + input.projectTokenId !== undefined + ? 'gitlabProjectToken' + : authType === 'pat' + ? 'gitlabPat' + : 'gitlabOAuth'; + return { + userId: input.userId, + authorization, + instanceUrl, + actor, + credentialKind, + client, + projectTokenId: input.projectTokenId, + // Integration scopes cannot describe a separate project token. + scopes: input.projectTokenId === undefined ? integration.scopes : null, + }; +} +export type GitLabReviewAuthorization = Awaited>; + +export async function resolveGitLabReviewProject( + auth: GitLabReviewAuthorization, + projectId: string, + expected?: RepositoryIdentity +) { + parseGitLab(z.string().regex(/^[1-9]\d*$/), projectId, 'invalid_request'); + if ( + expected && + (expected.provider !== 'gitlab' || + expected.repositoryId !== projectId || + normalizeReviewInstance(expected.instanceUrl) !== auth.instanceUrl) + ) + throw new GitLabInteractiveError('forbidden'); + const client = auth.client(projectId); + const result = await client.execute(api => api.Projects.show(projectId)); + const project = parseGitLab(GitLabProjectSchema, result.data); + const canonicalUrl = gitLabResourceUrl(auth.instanceUrl, project.path_with_namespace); + if ( + String(project.id) !== projectId || + new URL(project.web_url).toString() !== canonicalUrl || + (expected && expected.fullName !== project.path_with_namespace) + ) + throw new GitLabInteractiveError('not_found'); + const repository: RepositoryIdentity = { + provider: 'gitlab', + instanceUrl: auth.instanceUrl, + repositoryId: projectId, + fullName: project.path_with_namespace, + defaultBranch: project.default_branch ?? null, + }; + return { client, project, repository, canonicalUrl }; +} diff --git a/apps/web/src/lib/provider-review/gitlab-read.test.ts b/apps/web/src/lib/provider-review/gitlab-read.test.ts new file mode 100644 index 0000000000..796622ee05 --- /dev/null +++ b/apps/web/src/lib/provider-review/gitlab-read.test.ts @@ -0,0 +1,1570 @@ +jest.mock('@/lib/integrations/gitlab-service', () => ({ getGitLabIntegration: jest.fn() })); +jest.mock('@/lib/integrations/platforms/gitlab/credential-broker-client', () => ({ + fetchGitLabCredential: jest.fn(), +})); + +import type { PlatformIntegration } from '@kilocode/db/schema'; +import type { + OwnerIntegrationAuthorization, + RepositoryIdentity, +} from '@kilocode/app-shared/code-review/repository-identity'; +import { + reviewActionAvailability, + type ReviewCursor, + type ReviewIdentity, + type ReviewRevision, +} from '@kilocode/app-shared/provider-review'; +import { getGitLabIntegration } from '@/lib/integrations/gitlab-service'; +import { fetchGitLabCredential } from '@/lib/integrations/platforms/gitlab/credential-broker-client'; +import { authorizeGitLabReview } from './gitlab-authorization'; +import { + getGitLabChecks, + getGitLabFileContext, + getGitLabReview, + listGitLabDiffVersions, + listGitLabDiscussions, + listGitLabFiles, + listGitLabInbox, +} from './gitlab-read'; + +const instanceUrl = 'https://gitlab.com/GitLab'; +const integrationId = '11111111-1111-4111-8111-111111111111'; +const userId = 'oauth/current-user'; +const authorization: OwnerIntegrationAuthorization = { + kind: 'ownerIntegration', + owner: { type: 'user', id: userId }, + integrationId, +}; +const repository: RepositoryIdentity = { + provider: 'gitlab', + instanceUrl, + repositoryId: '123', + fullName: 'Group/Sub/Repo', + defaultBranch: 'trunk', +}; +const canonicalUrl = `${instanceUrl}/Group/Sub/Repo/-/merge_requests/7`; +const identity: ReviewIdentity = { + repository, + authorization, + number: '7', + reviewId: '77', + canonicalUrl, +}; +const revision: ReviewRevision = { + headSha: 'a'.repeat(40), + baseSha: 'b'.repeat(40), + startSha: 'c'.repeat(40), + targetHeadSha: null, +}; +const diff = { + old_path: 'src/old.ts', + new_path: 'src/new.ts', + new_file: false, + deleted_file: false, + renamed_file: true, + diff: '@@ -1 +1 @@\n-old\n+new', +}; +const position = { + position_type: 'text', + old_path: diff.old_path, + new_path: diff.new_path, + head_sha: revision.headSha, + base_sha: revision.baseSha, + start_sha: revision.startSha, + new_line: 6, + old_line: null, + line_range: { + start: { line_code: 'code-4', type: 'new', new_line: 4 }, + end: { line_code: 'code-6', type: 'new', new_line: 6 }, + }, +}; +const note = { + id: 8, + body: 'Review text', + created_at: '2026-08-29T12:00:00Z', + author: { id: 9, username: 'provider-actor' }, + resolvable: true, + resolved: true, + position, + current_user: { can_resolve: true }, +}; +const mergeRequest = { + id: 77, + iid: 7, + project_id: 123, + target_project_id: 123, + source_project_id: 123, + title: 'Nested review', + description: 'Details', + state: 'opened', + draft: false, + source_branch: 'feature', + target_branch: 'trunk', + sha: revision.headSha, + diff_refs: { + head_sha: revision.headSha, + base_sha: revision.baseSha, + start_sha: revision.startSha, + }, + web_url: canonicalUrl, + updated_at: '2026-08-29T12:00:00Z', + author: note.author, + user: { can_merge: true }, + detailed_merge_status: 'mergeable', + blocking_discussions_resolved: true, + merge_when_pipeline_succeeds: false, +}; +const project = { + id: 123, + path_with_namespace: repository.fullName, + web_url: `${instanceUrl}/${repository.fullName}`, + default_branch: 'trunk', + merge_method: 'ff', + squash_option: 'always', + permissions: { project_access: { access_level: 40 } }, +}; +const root = '/projects/123/merge_requests/7'; +const filePath = '/projects/123/repository/files/src%2Fnew.ts'; +const file = { + file_path: diff.new_path, + commit_id: revision.headSha, + encoding: 'base64', + content: Buffer.from('original\nsecond\nthird\n').toString('base64'), + size: 22, +}; +const contextInput = { + file: { oldPath: diff.old_path, newPath: diff.new_path, revision }, + side: 'new' as const, + startLine: 2, + lineCount: 2, +}; +let rows: Map; +let failures: Map; +let paged: Set; +let review: typeof mergeRequest; +let integration: PlatformIntegration; +let afterDiff: (() => void) | undefined; + +beforeEach(() => { + jest.resetAllMocks(); + review = { ...mergeRequest, diff_refs: { ...mergeRequest.diff_refs }, user: { can_merge: true } }; + integration = { + id: integrationId, + platform: 'gitlab', + owned_by_user_id: userId, + owned_by_organization_id: null, + integration_status: 'active', + integration_type: 'oauth', + suspended_at: null, + auth_invalid_at: null, + metadata: { gitlab_instance_url: instanceUrl }, + scopes: ['api'], + } as PlatformIntegration; + jest.mocked(getGitLabIntegration).mockImplementation(async () => integration); + jest.mocked(fetchGitLabCredential).mockResolvedValue({ + status: 'available', + token: 'test-credential', + instanceUrl, + glabIsOAuth2: true, + }); + rows = new Map([ + ['/user', { id: 9, username: 'provider-actor', name: 'Provider Actor' }], + ['/metadata', { version: '17.11.0', enterprise: true }], + ['/projects/123', { ...project }], + [ + '/projects/124', + { + ...project, + id: 124, + path_with_namespace: 'Fork/Sub/Repo', + web_url: `${instanceUrl}/Fork/Sub/Repo`, + }, + ], + ['/merge_requests', [review]], + ['/projects/123/merge_requests', [review]], + [root, review], + [`${root}/diffs`, [diff]], + [ + `${root}/versions`, + [ + { + id: 91, + head_commit_sha: revision.headSha, + base_commit_sha: revision.baseSha, + start_commit_sha: revision.startSha, + state: 'collected', + }, + ], + ], + [ + `${root}/versions/91`, + { + id: 91, + head_commit_sha: revision.headSha, + base_commit_sha: revision.baseSha, + start_commit_sha: revision.startSha, + state: 'collected', + real_size: '1', + diffs: [diff], + }, + ], + [`${root}/pipelines`, []], + [`/projects/123/repository/commits/${revision.headSha}/statuses`, []], + [`${root}/commits`, [{ id: revision.headSha }]], + [ + `${root}/approvals`, + { + approved: true, + approvals_required: 1, + approvals_left: 0, + approved_by: [{ user: note.author }], + }, + ], + [`${root}/approval_state`, { rules: [{ eligible_approvers: [note.author], approved: true }] }], + [`${root}/reviewers`, [{ user: note.author, state: 'requested_changes' }]], + [`${root}/discussions`, [{ id: 'thread-1', notes: [note] }]], + [ + `${root}/notes/8/award_emoji`, + [ + { id: 41, name: 'thumbsup', user: { id: 10 } }, + { id: 42, name: 'thumbsup', user: note.author }, + ], + ], + [`${filePath}@${revision.headSha}`, { ...file }], + [`/projects/124/repository/files/src%2Fnew.ts@${revision.headSha}`, { ...file }], + [ + `/projects/123/repository/files/src%2Fold.ts@${revision.baseSha}`, + { + ...file, + file_path: diff.old_path, + commit_id: revision.baseSha, + content: Buffer.from('base content\n').toString('base64'), + size: 13, + }, + ], + ]); + failures = new Map(); + paged = new Set(); + afterDiff = undefined; + global.fetch = jest.fn(async (destination, init) => { + if (init?.method !== 'GET') throw new Error('Read adapters cannot write'); + const url = new URL(String(destination)); + const path = url.pathname.replace('/GitLab/api/v4', ''); + const page = url.searchParams.get('page') ?? '1'; + const failure = failures.get(`${path}:${page}`) ?? failures.get(path); + if (failure) return Response.json({ message: 'test-credential' }, { status: failure }); + const key = url.searchParams.has('ref') ? `${path}@${url.searchParams.get('ref')}` : path; + let data = rows.get(`${key}:${page}`) ?? rows.get(key); + if (data === undefined) return Response.json({}, { status: 404 }); + if ( + path === '/merge_requests' && + url.searchParams.get('reviewer_id') !== '9' && + url.searchParams.get('author_id') !== '9' + ) + data = []; + if (page === '2' && path === '/merge_requests') + data = [ + { + ...review, + id: 78, + iid: 8, + title: 'Later review', + web_url: canonicalUrl.replace('/7', '/8'), + }, + ]; + const headers: Record = {}; + if (paged.has(path) && page === '1') { + url.searchParams.set('page', '2'); + headers.link = `<${url}>; rel="next"`; + headers['x-next-page'] = '2'; + } else headers['x-next-page'] = ''; + const response = Response.json(data, { headers }); + if (path.endsWith('/diffs')) afterDiff?.(); + return response; + }); +}); +const auth = () => + authorizeGitLabReview({ + userId, + authorization: integration.owned_by_organization_id + ? { ...authorization, owner: { type: 'org', id: integration.owned_by_organization_id } } + : authorization, + instanceUrl, + }); + +it('AC4 uses the provider actor for reviewer and authored inbox filters', async () => { + const selected = await auth(); + for (const filter of ['reviewer', 'author'] as const) { + const inbox = await listGitLabInbox(selected, { filter }); + expect(inbox.scope).toMatchObject({ + kind: 'actor', + actor: { id: '9', login: 'provider-actor' }, + }); + expect(inbox.items[0]).toMatchObject({ identity, title: 'Nested review' }); + } +}); +it('AC4 never presents an organization actor as a Personal inbox', async () => { + integration.owned_by_user_id = null; + integration.owned_by_organization_id = '22222222-2222-4222-8222-222222222222'; + const selected = await auth(); + await expect(listGitLabInbox(selected)).rejects.toMatchObject({ code: 'invalid_request' }); + const inbox = await listGitLabInbox(selected, { repository }); + expect(inbox.scope).toEqual({ kind: 'repository', actor: selected.actor, repository }); + expect(inbox.items[0].identity.authorization).toEqual(selected.authorization); +}); +it.each(['closed', 'merged'])( + 'AC4 keeps %s reviews readable with provider merge policy', + async state => { + review.state = state; + const result = await getGitLabReview(await auth(), repository, '7'); + expect(result).toMatchObject({ + identity, + state, + revision, + counts: { commits: 1, files: 1, additions: 1, deletions: 1 }, + merge: { methods: [{ id: 'ff', label: 'ff' }], squash: 'required' }, + }); + expect(result.authorization.capabilities.merge.restrictions).toContain(state); + } +); +it('AC4 distinguishes no checks, no reviews, no files, and no discussions', async () => { + for (const path of ['/merge_requests', `${root}/diffs`, `${root}/discussions`]) + rows.set(path, []); + const selected = await auth(); + expect((await listGitLabInbox(selected)).items).toEqual([]); + expect(await listGitLabFiles(selected, identity, revision)).toEqual({ + items: [], + nextCursor: null, + }); + expect(await listGitLabDiscussions(selected, identity)).toEqual({ items: [], nextCursor: null }); + expect((await getGitLabReview(selected, repository, '7')).checks).toEqual({ + status: 'none', + checks: [], + }); +}); +it('AC4 includes current pipelines and commit checks without stale-head failures', async () => { + rows.set(`${root}/pipelines`, [ + { id: 50, sha: 'd'.repeat(40), status: 'failed' }, + { + id: 51, + sha: revision.headSha, + status: 'running', + web_url: `${instanceUrl}/Group/Sub/Repo/-/pipelines/51`, + }, + ]); + rows.set(`/projects/123/repository/commits/${revision.headSha}/statuses`, [ + { id: 55, sha: revision.headSha, status: 'success', name: 'Build', allow_failure: false }, + ]); + expect(await getGitLabChecks(await auth(), identity)).toMatchObject({ + status: 'reported', + checks: [ + { id: 'pipeline:51', state: 'running' }, + { id: 'status:55', state: 'passed', required: true }, + ], + }); +}); +it.each([ + [403, 'unavailable'], + [503, 'temporarily_unavailable'], +] as const)('AC4 distinguishes check failure %s', async (status, expected) => { + failures.set(`${root}/pipelines`, status); + const selected = await auth(); + if (status === 403) + expect(await getGitLabChecks(selected, identity)).toMatchObject({ status: expected }); + else await expect(getGitLabChecks(selected, identity)).rejects.toMatchObject({ code: expected }); +}); +it.each([ + ['17.10.0', 'merge_when_pipeline_succeeds'], + ['17.11.0', 'auto_merge'], +])('AC4 derives auto-merge parameters from instance %s', async (version, method) => { + rows.set('/metadata', { version, enterprise: false }); + review.merge_when_pipeline_succeeds = true; + const result = await getGitLabReview(await auth(), repository, '7'); + expect(result.merge.autoMerge).toEqual({ method: 'ff' }); + expect(result.authorization.capabilities.enableAutoMerge.explanation).toBe(method); + expect(result.providerState).toMatchObject({ + provider: 'gitlab', + requestedChanges: { + actorIds: ['9'], + blocksMerge: false, + blockingCapability: { license: 'unavailable' }, + }, + }); + expect(result.authorization.capabilities.requestChanges).toMatchObject({ + support: 'supported', + version: 'available', + license: 'available', + }); +}); +it('AC4 does not claim an enterprise build proves licensed merge blocking', async () => { + const result = await getGitLabReview(await auth(), repository, '7'); + expect(result.providerState).toMatchObject({ + requestedChanges: { blocksMerge: null, blockingCapability: { license: 'unknown' } }, + }); + review.detailed_merge_status = 'requested_changes'; + expect((await getGitLabReview(await auth(), repository, '7')).providerState).toMatchObject({ + requestedChanges: { blocksMerge: true, blockingCapability: { license: 'available' } }, + }); +}); +it('AC4 preserves readable content while denying missing write grants', async () => { + integration.scopes = ['read_api']; + const selected = await auth(); + const result = await getGitLabReview(selected, repository, '7'); + expect(result.authorization.capabilities.read.permission).toBe('allowed'); + for (const action of ['comment', 'approve', 'unapprove', 'merge'] as const) + expect(result.authorization.capabilities[action]).toMatchObject({ + permission: 'forbidden', + recovery: 'reconnect', + }); + expect( + (await listGitLabDiscussions(selected, identity)).items[0].capabilities.resolveThread + ?.permission + ).toBe('forbidden'); +}); +it('AC4 reports provider merge restrictions and denied merge permission separately', async () => { + rows.set('/projects/123', { + ...project, + only_allow_merge_if_pipeline_succeeds: true, + only_allow_merge_if_all_discussions_are_resolved: true, + }); + review.blocking_discussions_resolved = false; + review.user.can_merge = false; + const capability = (await getGitLabReview(await auth(), repository, '7')).authorization + .capabilities.merge; + expect(capability.permission).toBe('forbidden'); + expect(capability.restrictions).toEqual( + expect.arrayContaining(['discussions_not_resolved', 'pipeline_not_successful']) + ); +}); + +it('AC5 distinguishes unprepared diffs from an empty changed-file list', async () => { + rows.set(root, { ...review, diff_refs: null }); + rows.set(`${root}/diffs`, []); + await expect( + listGitLabFiles(await auth(), identity, { ...revision, baseSha: null, startSha: null }) + ).rejects.toMatchObject({ code: 'temporarily_unavailable' }); +}); +it('AC5 preserves rename paths and base/head/start revisions', async () => { + const files = await listGitLabFiles(await auth(), identity, revision); + expect(files.items[0]).toMatchObject({ + oldPath: diff.old_path, + newPath: diff.new_path, + revision, + status: 'renamed', + patch: diff.diff, + additions: 1, + deletions: 1, + canonicalUrl: `${instanceUrl}/Group/Sub/Repo/-/blob/${revision.headSha}/src/new.ts`, + }); +}); +it.each(['new_file', 'deleted_file'])('AC5 preserves both native paths for %s', async flag => { + rows.set(`${root}/diffs`, [{ ...diff, renamed_file: false, [flag]: true }]); + expect((await listGitLabFiles(await auth(), identity, revision)).items[0]).toMatchObject({ + oldPath: diff.old_path, + newPath: diff.new_path, + }); +}); +it('AC5 reads immutable old-side context and fork new-side context', async () => { + review.source_project_id = 124; + const selected = await auth(); + const old = await getGitLabFileContext(selected, identity, { + ...contextInput, + side: 'old', + startLine: 1, + }); + expect(old).toMatchObject({ + content: 'available', + path: diff.old_path, + lines: ['base content'], + revision, + canonicalUrl: `${instanceUrl}/Group/Sub/Repo/-/blob/${revision.baseSha}/src/old.ts`, + }); + const current = await getGitLabFileContext(selected, identity, contextInput); + expect(current).toMatchObject({ + content: 'available', + lines: ['second', 'third'], + totalLines: 3, + canonicalUrl: `${instanceUrl}/Fork/Sub/Repo/-/blob/${revision.headSha}/src/new.ts`, + }); +}); +it('AC5 reads a historical version without relabeling it as the current head', async () => { + const old = { ...revision, headSha: 'd'.repeat(40) }; + rows.set(`${root}/versions`, [ + { + id: 91, + head_commit_sha: old.headSha, + base_commit_sha: old.baseSha, + start_commit_sha: old.startSha, + }, + ]); + rows.set(`${root}/versions/91`, { + id: 91, + head_commit_sha: old.headSha, + base_commit_sha: old.baseSha, + start_commit_sha: old.startSha, + state: 'collected', + real_size: '1', + diffs: [diff], + }); + rows.set(`${filePath}@${old.headSha}`, { ...file, commit_id: old.headSha }); + const selected = await auth(); + expect((await listGitLabDiffVersions(selected, identity)).items).toEqual([ + { id: '91', revision: old }, + ]); + expect((await listGitLabFiles(selected, identity, old, null, '91')).items[0].revision).toEqual( + old + ); + expect( + await getGitLabFileContext(selected, identity, { + ...contextInput, + file: { ...contextInput.file, revision: old }, + versionId: '91', + }) + ).toMatchObject({ revision: old, lines: ['second', 'third'] }); + await expect(listGitLabFiles(selected, identity, revision, null, '91')).rejects.toMatchObject({ + code: 'conflict', + }); +}); +it('AC5 rejects a changed head instead of silently retargeting selected context', async () => { + review.sha = 'd'.repeat(40); + review.diff_refs.head_sha = review.sha; + await expect(getGitLabFileContext(await auth(), identity, contextInput)).rejects.toMatchObject({ + code: 'conflict', + }); +}); +it('AC5 rejects a head change during a diff page read', async () => { + afterDiff = () => { + review.sha = 'd'.repeat(40); + review.diff_refs.head_sha = review.sha; + }; + await expect(listGitLabFiles(await auth(), identity, revision)).rejects.toMatchObject({ + code: 'conflict', + }); +}); +it.each([ + [Buffer.from([0, 1]), 2, 'binary'], + [Buffer.from([255]), 1, 'binary'], + [Buffer.from('partial'), 100, 'truncated'], +] as const)('AC5 explains unavailable text content %s', async (bytes, size, content) => { + rows.set(`${filePath}@${revision.headSha}`, { ...file, content: bytes.toString('base64'), size }); + expect(await getGitLabFileContext(await auth(), identity, contextInput)).toMatchObject({ + content, + lines: [], + canonicalUrl: `${instanceUrl}/Group/Sub/Repo/-/blob/${revision.headSha}/src/new.ts`, + }); +}); +it.each([ + [404, 'unavailable'], + [503, 'temporarily_unavailable'], +] as const)('AC5 distinguishes unavailable and retryable context %s', async (status, expected) => { + failures.set(filePath, status); + const selected = await auth(); + if (status === 404) + expect(await getGitLabFileContext(selected, identity, contextInput)).toMatchObject({ + content: expected, + }); + else { + await expect(getGitLabFileContext(selected, identity, contextInput)).rejects.toMatchObject({ + code: expected, + }); + failures.clear(); + expect((await getGitLabFileContext(selected, identity, contextInput)).lines).toEqual([ + 'second', + 'third', + ]); + } +}); +it.each([{ commit_id: 'd'.repeat(40) }, { file_path: 'wrong.ts' }, { content: 'invalid base64!' }])( + 'AC5 rejects mismatched or corrupt file data: %j', + async change => { + rows.set(`${filePath}@${revision.headSha}`, { ...file, ...change }); + await expect(getGitLabFileContext(await auth(), identity, contextInput)).rejects.toMatchObject({ + code: 'content' in change ? 'invalid_response' : 'conflict', + }); + } +); +it.each([{ too_large: true }, { collapsed: true }, { binary: true }])( + 'AC5 preserves provider content limits: %j', + async change => { + rows.set(`${root}/diffs`, [{ ...diff, ...change }]); + expect((await listGitLabFiles(await auth(), identity, revision)).items[0]).toMatchObject({ + content: 'binary' in change ? 'binary' : 'truncated', + patch: null, + }); + } +); + +it('AC6 separates resolution from outdatedness and preserves ranges, actors, and reactions', async () => { + rows.set(`${root}/discussions`, [ + { id: 'current', notes: [note] }, + { + id: 'old', + notes: [{ ...note, resolved: false, position: { ...position, head_sha: 'd'.repeat(40) } }], + }, + ]); + const result = await listGitLabDiscussions(await auth(), identity); + expect(result.items[0]).toMatchObject({ + resolved: true, + outdated: false, + position: { + revision, + oldPath: diff.old_path, + newPath: diff.new_path, + side: 'new', + line: 6, + startLine: 4, + native: { provider: 'gitlab', lineRange: { start: { lineCode: 'code-4' } } }, + }, + }); + expect(result.items[0].comments.items[0]).toMatchObject({ + author: { id: '9' }, + reactions: [{ id: '42', content: 'thumbsup', count: 2, viewerHasReacted: true }], + }); + expect(result.items[1]).toMatchObject({ resolved: false, outdated: true }); +}); +it('AC6 preserves conversation notes with a deleted author', async () => { + rows.set(`${root}/discussions`, [ + { id: 'conversation', notes: [{ ...note, author: null, position: null, resolvable: false }] }, + ]); + expect((await listGitLabDiscussions(await auth(), identity)).items[0]).toMatchObject({ + subjectType: 'conversation', + position: null, + file: null, + outdated: null, + resolved: null, + comments: { items: [{ author: null, bodyMarkdown: 'Review text' }] }, + }); +}); +describe('Gitbeaker read request contracts', () => { + it('AC4–AC5 preserves empty version and check results', async () => { + rows.set(`${root}/versions`, []); + const selected = await auth(); + expect(await listGitLabDiffVersions(selected, identity)).toEqual({ + items: [], + nextCursor: null, + }); + expect(await getGitLabChecks(selected, identity)).toEqual({ status: 'none', checks: [] }); + }); + + it('AC5 keeps diff-version pages separate with the requested page size', async () => { + const path = `${root}/versions`; + const versions = Array.from({ length: 26 }, (_, index) => ({ + id: 91 + index, + head_commit_sha: revision.headSha, + base_commit_sha: revision.baseSha, + start_commit_sha: revision.startSha, + })); + rows.set(path, versions.slice(0, 25)); + rows.set(`${path}:2`, versions.slice(25)); + paged.add(path); + const selected = await auth(); + const first = await listGitLabDiffVersions(selected, identity); + expect(first.items).toHaveLength(25); + expect(first.items[0]).toEqual({ id: '91', revision }); + expect(first.items[24]).toEqual({ id: '115', revision }); + expect(await listGitLabDiffVersions(selected, identity, first.nextCursor)).toEqual({ + items: [{ id: '116', revision }], + nextCursor: null, + }); + const queries = jest + .mocked(global.fetch) + .mock.calls.map(([destination]) => new URL(String(destination))) + .filter(url => url.pathname.endsWith(path)) + .map(url => Object.fromEntries(url.searchParams)); + expect(queries).toEqual([ + { page: '1', per_page: '25' }, + { page: '2', per_page: '25' }, + ]); + }); + + it('AC4 reads every status page without changing the latest-check query', async () => { + const path = `/projects/123/repository/commits/${revision.headSha}/statuses`; + const status = { + id: 55, + sha: revision.headSha, + status: 'success', + name: 'Build', + allow_failure: false, + }; + rows.set( + path, + Array.from({ length: 100 }, (_, index) => ({ ...status, id: 1000 + index })) + ); + rows.set(`${path}:2`, [ + { ...status, id: 2000, status: 'failed', name: 'Optional', allow_failure: true }, + { ...status, id: 2001, sha: 'd'.repeat(40), status: 'failed' }, + ]); + paged.add(path); + const result = await getGitLabChecks(await auth(), identity); + expect(result).toMatchObject({ + status: 'reported', + checks: expect.arrayContaining([ + { id: 'status:1000', state: 'passed', name: 'Build', required: true, detailsUrl: null }, + { id: 'status:1099', state: 'passed', name: 'Build', required: true, detailsUrl: null }, + { id: 'status:2000', state: 'failed', name: 'Optional', required: false, detailsUrl: null }, + ]), + }); + if (result.status === 'reported') expect(result.checks).toHaveLength(101); + const queries = jest + .mocked(global.fetch) + .mock.calls.map(([destination]) => new URL(String(destination))) + .filter(url => url.pathname.endsWith(path)) + .map(url => Object.fromEntries(url.searchParams)); + expect(queries).toEqual([{ per_page: '100' }, { per_page: '100', page: '2' }]); + }); + + it.each([ + [401, 'reconnect_required'], + [403, 'unavailable'], + [503, 'temporarily_unavailable'], + ] as const)('AC4 never reports partial checks after a later-page %s', async (status, code) => { + const path = `/projects/123/repository/commits/${revision.headSha}/statuses`; + rows.set(path, [{ id: 55, sha: revision.headSha, status: 'success', name: 'Build' }]); + rows.set(`${path}:2`, [{ id: 56, sha: revision.headSha, status: 'failed', name: 'Later' }]); + paged.add(path); + failures.set(`${path}:2`, status); + const selected = await auth(); + const result = getGitLabChecks(selected, identity); + if (status === 403) + await expect(result).resolves.toEqual({ + status: 'unavailable', + explanation: 'forbidden_or_unavailable', + }); + else + await expect(result).rejects.toMatchObject({ + code, + message: `GitLab interactive request failed: ${code} (${status})`, + }); + if (status === 503) { + failures.clear(); + expect(await getGitLabChecks(selected, identity)).toMatchObject({ + status: 'reported', + checks: [ + { id: 'status:55', state: 'passed' }, + { id: 'status:56', state: 'failed' }, + ], + }); + } + }); +}); + +it.each(['inbox', 'files', 'versions', 'discussions'] as const)( + 'AC4–AC6 retains the loaded %s page across a later-page failure and retry', + async surface => { + const path = + surface === 'inbox' + ? '/merge_requests' + : `${root}/${surface === 'files' ? 'diffs' : surface}`; + paged.add(path); + const selected = await auth(); + const read = (cursor?: ReviewCursor | null) => + surface === 'inbox' + ? listGitLabInbox(selected, { cursor }) + : surface === 'files' + ? listGitLabFiles(selected, identity, revision, cursor) + : surface === 'versions' + ? listGitLabDiffVersions(selected, identity, cursor) + : listGitLabDiscussions(selected, identity, cursor); + const first = await read(); + const retained = JSON.stringify(first); + expect(first.nextCursor).not.toBeNull(); + failures.set(`${path}:2`, 503); + await expect(read(first.nextCursor)).rejects.toMatchObject({ code: 'temporarily_unavailable' }); + expect(JSON.stringify(first)).toBe(retained); + failures.clear(); + const second = await read(first.nextCursor); + expect(second.nextCursor).toBeNull(); + expect(second.items).toHaveLength(1); + if (surface === 'inbox') expect(second.items[0]).toMatchObject({ title: 'Later review' }); + } +); +it('AC4–AC6 rejects cursors from another actor, revision, or surface', async () => { + paged.add(`${root}/diffs`); + const selected = await auth(); + const first = await listGitLabFiles(selected, identity, revision); + await expect( + listGitLabFiles( + { ...selected, actor: { ...selected.actor, id: '10' } }, + identity, + revision, + first.nextCursor + ) + ).rejects.toMatchObject({ code: 'invalid_request' }); + await expect( + listGitLabFiles(selected, identity, { ...revision, headSha: 'd'.repeat(40) }, first.nextCursor) + ).rejects.toMatchObject({ code: 'invalid_request' }); + await expect(listGitLabDiscussions(selected, identity, first.nextCursor)).rejects.toMatchObject({ + code: 'invalid_request', + }); +}); +it('AC4–AC6 rejects a foreign owner and a wrong provider review ID', async () => { + const selected = await auth(); + await expect( + listGitLabDiscussions(selected, { + ...identity, + authorization: { ...authorization, integrationId: '33333333-3333-4333-8333-333333333333' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }); + await expect( + listGitLabFiles(selected, { ...identity, reviewId: 'other-review' }, revision) + ).rejects.toMatchObject({ code: 'not_found' }); +}); +it('AC4–AC6 rejects malformed provider records rather than returning empty success', async () => { + rows.set(`${root}/discussions`, [{ id: 'broken', notes: [] }]); + await expect(listGitLabDiscussions(await auth(), identity)).rejects.toMatchObject({ + code: 'invalid_response', + }); +}); +it('AC4 rejects checks requested for a stale displayed revision', async () => { + await expect( + getGitLabChecks(await auth(), identity, { ...revision, headSha: 'd'.repeat(40) }) + ).rejects.toMatchObject({ code: 'conflict' }); +}); +it('AC5 keeps an inaccessible fork readable without borrowing source access', async () => { + review.source_project_id = 124; + failures.set('/projects/124', 403); + const selected = await auth(); + expect(await getGitLabReview(selected, repository, '7')).toMatchObject({ + identity, + source: { repository: null, branch: 'feature' }, + }); + expect(await getGitLabFileContext(selected, identity, contextInput)).toMatchObject({ + content: 'unavailable', + lines: [], + canonicalUrl, + }); +}); +it('AC4 preserves unknown version and license states without claiming provider limitations', async () => { + for (const path of ['/metadata', `${root}/approvals`, `${root}/reviewers`]) + failures.set(path, 404); + const result = await getGitLabReview(await auth(), repository, '7'); + expect(result.authorization.capabilities.enableAutoMerge).toMatchObject({ + support: 'supported', + version: 'unknown', + recovery: 'openProvider', + }); + expect(result.authorization.capabilities.requestChanges).toMatchObject({ + support: 'supported', + version: 'unknown', + recovery: 'openProvider', + }); + expect(result.providerState).toMatchObject({ + approvals: { approved: null, required: null }, + requestedChanges: { blocksMerge: null }, + }); +}); +it('AC4 permits scheduling and cancellation while a required pipeline runs', async () => { + rows.set('/projects/123', { ...project, only_allow_merge_if_pipeline_succeeds: true }); + rows.set(`${root}/pipelines`, [{ id: 51, sha: revision.headSha, status: 'running' }]); + review.detailed_merge_status = 'ci_still_running'; + review.merge_when_pipeline_succeeds = true; + const capabilities = (await getGitLabReview(await auth(), repository, '7')).authorization + .capabilities; + expect(capabilities.merge.restrictions).toContain('ci_still_running'); + expect(capabilities.enableAutoMerge.restrictions).toEqual([]); + expect(capabilities.disableAutoMerge.restrictions).toEqual([]); +}); +it('AC5 bounds decoded context after JSON escaping expands the content', async () => { + const bytes = Buffer.from('\u0001'.repeat(2 * 1024 * 1024)); + rows.set(`${filePath}@${revision.headSha}`, { + ...file, + content: bytes.toString('base64'), + size: bytes.length, + }); + expect( + await getGitLabFileContext(await auth(), identity, { ...contextInput, startLine: 1 }) + ).toMatchObject({ content: 'truncated', lines: [] }); +}); +it('AC5 exposes a bounded response as truncated context with an exact file URL', async () => { + const fetch = global.fetch; + global.fetch = jest.fn(async (destination, init) => + String(destination).includes('/repository/files/') + ? Response.json({}, { headers: { 'content-length': String(10 * 1024 * 1024 + 1) } }) + : fetch(destination, init) + ); + expect(await getGitLabFileContext(await auth(), identity, contextInput)).toMatchObject({ + content: 'truncated', + lines: [], + canonicalUrl: `${instanceUrl}/Group/Sub/Repo/-/blob/${revision.headSha}/src/new.ts`, + }); +}); +it('AC5 returns an empty text file without confusing it with unavailable content', async () => { + rows.set(`${filePath}@${revision.headSha}`, { ...file, content: '', size: 0 }); + expect( + await getGitLabFileContext(await auth(), identity, { ...contextInput, startLine: 1 }) + ).toMatchObject({ content: 'available', totalLines: 0, lines: [] }); +}); +it.each([0, 501])('AC5 rejects an invalid context line count %s', async lineCount => { + await expect( + getGitLabFileContext(await auth(), identity, { ...contextInput, lineCount }) + ).rejects.toMatchObject({ code: 'invalid_request' }); +}); +it('AC5 rejects a provider path that escapes the selected repository', async () => { + rows.set(`${root}/diffs`, [{ ...diff, new_path: '../Other/private.ts' }]); + await expect(listGitLabFiles(await auth(), identity, revision)).rejects.toMatchObject({ + code: 'invalid_response', + }); +}); +it.each(['overflow', 'collected'])('AC5 exposes a limited version with %s state', async state => { + rows.set(`${root}/versions/91`, { + id: 91, + head_commit_sha: revision.headSha, + base_commit_sha: revision.baseSha, + start_commit_sha: revision.startSha, + state, + real_size: '1000+', + diffs: [], + }); + await expect(listGitLabFiles(await auth(), identity, revision, null, '91')).rejects.toMatchObject( + { code: 'response_too_large' } + ); +}); +it('AC6 reads a single old-side position with a null line range', async () => { + rows.set(`${root}/discussions`, [ + { + id: 'old-line', + notes: [ + { ...note, position: { ...position, line_range: null, old_line: 5, new_line: null } }, + ], + }, + ]); + expect((await listGitLabDiscussions(await auth(), identity)).items[0].position).toMatchObject({ + side: 'old', + line: 5, + native: { oldLine: 5, newLine: null }, + }); +}); +it('AC6 uses the old end side of a range even when both line numbers exist', async () => { + rows.set(`${root}/discussions`, [ + { + id: 'old-range', + notes: [ + { + ...note, + position: { + ...position, + old_line: 5, + new_line: 6, + line_range: { + start: { type: 'old', line_code: 'old-3', old_line: 3 }, + end: { type: 'old', line_code: 'old-5', old_line: 5 }, + }, + }, + }, + ], + }, + ]); + expect((await listGitLabDiscussions(await auth(), identity)).items[0].position).toMatchObject({ + side: 'old', + line: 5, + startSide: 'old', + startLine: 3, + native: { oldLine: 5, newLine: 6 }, + }); +}); +it('AC6 bounds combined reaction results across separate provider responses', async () => { + rows.set(`${root}/discussions`, [{ id: 'large-reactions', notes: [note, { ...note, id: 9 }] }]); + const awards = [{ id: 42, name: 'x'.repeat(6 * 1024 * 1024), user: note.author }]; + rows.set(`${root}/notes/8/award_emoji`, awards); + rows.set(`${root}/notes/9/award_emoji`, awards); + await expect(listGitLabDiscussions(await auth(), identity)).rejects.toMatchObject({ + code: 'response_too_large', + }); +}); +it.each(['pipelines', 'statuses'])( + 'AC4 rejects an incomplete %s aggregate at the SDK page ceiling', + async surface => { + const fetch = global.fetch; + global.fetch = jest.fn(async (destination, init) => { + const url = new URL(String(destination)); + if (!url.pathname.endsWith(`/${surface}`)) return fetch(destination, init); + const current = Number(url.searchParams.get('page') ?? 1); + url.searchParams.set('page', String(current + 1)); + return Response.json( + [{ id: current, sha: revision.headSha, status: 'running', name: 'Build' }], + { + headers: { link: `<${url}>; rel="next"`, 'x-next-page': String(current + 1) }, + } + ); + }); + await expect(getGitLabChecks(await auth(), identity)).rejects.toMatchObject({ + code: 'pagination_limit', + }); + const requests = jest + .mocked(global.fetch) + .mock.calls.filter(([destination]) => + new URL(String(destination)).pathname.endsWith(`/${surface}`) + ); + expect(requests).toHaveLength(100); + } +); +it('AC6 bounds discussion expansion instead of dropping notes', async () => { + rows.set(`${root}/discussions`, [ + { id: 'large', notes: Array.from({ length: 101 }, () => note) }, + ]); + await expect(listGitLabDiscussions(await auth(), identity)).rejects.toMatchObject({ + code: 'response_too_large', + }); +}); +it('AC6 derives approval eligibility and withdrawal state from documented responses', async () => { + const selected = await auth(); + const first = await getGitLabReview(selected, repository, '7'); + expect(first.authorization.capabilities.approve.permission).toBe('allowed'); + expect(first.authorization.capabilities.unapprove).toMatchObject({ + permission: 'allowed', + restrictions: [], + }); + rows.set(`${root}/approvals`, { approved: true, approved_by: [{ user: { id: 10 } }] }); + const other = await getGitLabReview(selected, repository, '7'); + expect(other.providerState).toMatchObject({ approvals: { actorIds: ['10'] } }); + expect(other.authorization.capabilities.unapprove).toMatchObject({ + permission: 'allowed', + restrictions: ['not_approved'], + }); +}); +it.each([ + [40, 10, 'allowed'], + [20, 9, 'allowed'], + [20, 10, 'forbidden'], +] as const)( + 'AC6 derives resolution permission from role %s and MR author %s', + async (accessLevel, authorId, permission) => { + rows.set('/projects/123', { + ...project, + permissions: { project_access: { access_level: accessLevel } }, + }); + rows.set(root, { ...review, author: { id: authorId } }); + rows.set(`${root}/discussions`, [ + { id: 'native', notes: [{ ...note, current_user: undefined }] }, + ]); + expect( + (await listGitLabDiscussions(await auth(), identity)).items[0].capabilities.resolveThread + ?.permission + ).toBe(permission); + } +); + +describe('c1-r3 provider read regressions', () => { + it.each(['allowed failure', 'superseded pipeline'] as const)( + 'keeps a successful current pipeline mergeable with an %s', + async failure => { + const current = { id: 52, sha: revision.headSha, status: 'success' }; + rows.set(root, { ...review, head_pipeline: current }); + rows.set('/projects/123', { ...project, only_allow_merge_if_pipeline_succeeds: true }); + rows.set(`${root}/pipelines`, [ + ...(failure === 'superseded pipeline' + ? [{ id: 51, sha: revision.headSha, status: 'failed' }] + : []), + current, + ]); + if (failure === 'allowed failure') + rows.set(`/projects/123/repository/commits/${revision.headSha}/statuses`, [ + { + id: 55, + sha: revision.headSha, + status: 'failed', + name: 'Optional', + allow_failure: true, + }, + ]); + const result = await getGitLabReview(await auth(), repository, '7'); + expect(reviewActionAvailability(result.authorization.capabilities.merge)).toBe('available'); + expect(result.checks).toMatchObject({ + status: 'reported', + checks: expect.arrayContaining([ + { + id: failure === 'allowed failure' ? 'status:55' : 'pipeline:51', + name: failure === 'allowed failure' ? 'Optional' : '#51', + state: 'failed', + required: false, + detailsUrl: null, + }, + ]), + }); + } + ); + it.each([ + ['failed', false, 'restricted'], + ['skipped', false, 'restricted'], + ['skipped', true, 'available'], + ] as const)( + 'applies current pipeline %s with skipped policy %s', + async (status, allowSkipped, expected) => { + const current = { id: 52, sha: 'd'.repeat(40), status }; + rows.set(root, { ...review, head_pipeline: current }); + rows.set('/projects/123', { + ...project, + only_allow_merge_if_pipeline_succeeds: true, + allow_merge_on_skipped_pipeline: allowSkipped, + }); + rows.set(`${root}/pipelines`, [ + { id: 51, sha: revision.headSha, status: 'success' }, + current, + ]); + const result = await getGitLabReview(await auth(), repository, '7'); + expect(reviewActionAvailability(result.authorization.capabilities.merge)).toBe(expected); + } + ); + + it.each([ + ['files', 'overflow', '1000+'], + ['overview', 'overflow', '1000+'], + ['context', 'overflow', '1000+'], + ['files', 'collected', '2'], + ['overview', 'collected', '2'], + ['context', 'collected', '2'], + ['files', 'collected', '1000+'], + ['overview', 'collected', '1000+'], + ['context', 'collected', '1000+'], + ] as const)( + 'rejects limited %s with %s metadata and size %s', + async (surface, state, realSize) => { + rows.set(`${root}/versions`, [ + { + id: 91, + head_commit_sha: revision.headSha, + base_commit_sha: revision.baseSha, + start_commit_sha: revision.startSha, + state, + real_size: realSize, + }, + ]); + const selected = await auth(); + const result = + surface === 'files' + ? listGitLabFiles(selected, identity, revision) + : surface === 'overview' + ? getGitLabReview(selected, repository, '7') + : getGitLabFileContext(selected, identity, contextInput); + await expect(result).rejects.toMatchObject({ code: 'response_too_large' }); + } + ); + it('keeps complete current reads available beside an older overflow version', async () => { + rows.set(`${root}/versions`, [ + { + id: 91, + head_commit_sha: revision.headSha, + base_commit_sha: revision.baseSha, + start_commit_sha: revision.startSha, + state: 'collected', + real_size: '1', + }, + { + id: 90, + head_commit_sha: 'd'.repeat(40), + base_commit_sha: revision.baseSha, + start_commit_sha: revision.startSha, + state: 'overflow', + real_size: '1000+', + }, + ]); + const selected = await auth(); + expect((await listGitLabFiles(selected, identity, revision)).items).toHaveLength(1); + expect((await getGitLabReview(selected, repository, '7')).counts.files).toBe(1); + expect(await getGitLabFileContext(selected, identity, contextInput)).toMatchObject({ + content: 'available', + lines: ['second', 'third'], + }); + expect((await listGitLabDiffVersions(selected, identity)).items).toHaveLength(2); + }); + + it('returns completed empty current files and overview counts', async () => { + rows.set(`${root}/versions`, [ + { + id: 91, + head_commit_sha: revision.headSha, + base_commit_sha: revision.baseSha, + start_commit_sha: revision.startSha, + state: 'empty', + real_size: '0', + }, + ]); + rows.set(`${root}/diffs`, []); + const selected = await auth(); + expect(await listGitLabFiles(selected, identity, revision)).toMatchObject({ + items: [], + nextCursor: null, + }); + expect((await getGitLabReview(selected, repository, '7')).counts.files).toBe(0); + }); + + it.each(['timeout', 'unknown', 'empty', undefined])( + 'keeps current diff state %s unavailable', + async state => { + rows.set(`${root}/versions`, [ + { + id: 91, + head_commit_sha: revision.headSha, + base_commit_sha: revision.baseSha, + start_commit_sha: revision.startSha, + state, + }, + ]); + await expect(listGitLabFiles(await auth(), identity, revision)).rejects.toMatchObject({ + code: 'temporarily_unavailable', + }); + } + ); + it('does not use another revision to prove current diff completeness', async () => { + rows.set(`${root}/versions`, [ + { + id: 91, + head_commit_sha: 'd'.repeat(40), + base_commit_sha: revision.baseSha, + start_commit_sha: revision.startSha, + state: 'collected', + }, + ]); + await expect(listGitLabFiles(await auth(), identity, revision)).rejects.toMatchObject({ + code: 'temporarily_unavailable', + }); + }); + it('keeps historical files and context readable when the current diff overflows', async () => { + const historical = { ...revision, headSha: 'd'.repeat(40) }; + rows.set(`${root}/versions`, [ + { + id: 92, + head_commit_sha: revision.headSha, + base_commit_sha: revision.baseSha, + start_commit_sha: revision.startSha, + state: 'overflow', + }, + ]); + rows.set(`${root}/versions/91`, { + id: 91, + head_commit_sha: historical.headSha, + base_commit_sha: historical.baseSha, + start_commit_sha: historical.startSha, + state: 'collected', + diffs: [diff], + }); + rows.set(`${filePath}@${historical.headSha}`, { ...file, commit_id: historical.headSha }); + const selected = await auth(); + expect( + (await listGitLabFiles(selected, identity, historical, null, '91')).items[0] + ).toMatchObject({ + revision: historical, + patch: diff.diff, + }); + expect( + await getGitLabFileContext(selected, identity, { + ...contextInput, + file: { ...contextInput.file, revision: historical }, + versionId: '91', + }) + ).toMatchObject({ revision: historical, content: 'available', lines: ['second', 'third'] }); + }); + + it.each([ + ['assigned developer', 30, 10, false, true, 'allowed'], + ['assigned author', 10, 9, false, true, 'allowed'], + ['assigned assignee', 10, 10, true, true, 'allowed'], + ['unproved project permission', undefined, 10, false, true, 'unknown'], + ['reader role', 10, 10, false, true, 'unknown'], + ['unassigned actor', 40, 9, false, false, 'forbidden'], + ] as const)( + 'derives request-changes permission for an %s', + async (_label, accessLevel, authorId, assignee, assigned, permission) => { + rows.set('/projects/123', { + ...project, + permissions: + accessLevel === undefined + ? undefined + : { + project_access: { access_level: accessLevel }, + group_access: null, + }, + }); + rows.set(root, { + ...review, + author: { id: authorId }, + assignees: assignee ? [note.author] : [], + }); + rows.set(`${root}/reviewers`, [ + ...(assigned ? [{ user: note.author, state: 'unreviewed' }] : []), + { user: { id: 10 }, state: 'requested_changes' }, + ]); + const result = await getGitLabReview(await auth(), repository, '7'); + const capability = result.authorization.capabilities.requestChanges; + expect(capability.permission).toBe(permission); + expect(reviewActionAvailability(capability)).toBe( + permission === 'allowed' ? 'available' : permission + ); + expect(result.providerState).toMatchObject({ requestedChanges: { actorIds: ['10'] } }); + } + ); + it('keeps an unavailable reviewer assignment unknown', async () => { + failures.set(`${root}/reviewers`, 403); + const capability = (await getGitLabReview(await auth(), repository, '7')).authorization + .capabilities.requestChanges; + expect(capability.permission).toBe('unknown'); + expect(reviewActionAvailability(capability)).toBe('unknown'); + }); + it('does not infer request-changes support from an assigned actor alone', async () => { + rows.set(`${root}/reviewers`, [{ user: note.author, state: 'unreviewed' }]); + const capability = (await getGitLabReview(await auth(), repository, '7')).authorization + .capabilities.requestChanges; + expect(capability).toMatchObject({ permission: 'allowed', version: 'unknown' }); + expect(reviewActionAvailability(capability)).toBe('unknown'); + }); + it.each([ + [['read_api'], true, 'forbidden', 'reconnect'], + [null, true, 'unknown', 'openProvider'], + [null, false, 'forbidden', 'openProvider'], + ] as const)( + 'retains request-changes grant and assignment limits: %j, %s', + async (scopes, assigned, permission, recovery) => { + integration.scopes = scopes === null ? null : [...scopes]; + rows.set(`${root}/reviewers`, [ + ...(assigned ? [{ user: note.author, state: 'unreviewed' }] : []), + { user: { id: 10 }, state: 'requested_changes' }, + ]); + const capability = (await getGitLabReview(await auth(), repository, '7')).authorization + .capabilities.requestChanges; + expect(capability).toMatchObject({ permission, recovery }); + expect(reviewActionAvailability(capability)).toBe(permission); + } + ); + + it.each([ + ['open reply', true, true, false, false], + ['fully resolved', true, true, true, true], + ['unknown reply resolution', true, true, undefined, null], + ['known open reply after unknown first note', undefined, true, false, false], + ['non-resolvable reply', true, false, false, true], + ['unknown reply resolvability', true, undefined, false, null], + ] as const)( + 'aggregates %s independently from outdatedness', + async (_label, firstResolved, replyResolvable, replyResolved, resolved) => { + const notes = [ + { ...note, resolved: firstResolved }, + { ...note, id: 9, resolvable: replyResolvable, resolved: replyResolved, position: null }, + ]; + rows.set(`${root}/notes/9/award_emoji`, []); + rows.set(`${root}/discussions`, [ + { id: 'current', notes }, + { + id: 'outdated', + notes: [{ ...notes[0], position: { ...position, head_sha: 'd'.repeat(40) } }, notes[1]], + }, + ]); + const result = await listGitLabDiscussions(await auth(), identity); + expect( + result.items.map(thread => ({ resolved: thread.resolved, outdated: thread.outdated })) + ).toEqual([ + { resolved, outdated: false }, + { resolved, outdated: true }, + ]); + } + ); +}); + +describe('c1 post-takeover diff regressions', () => { + // https://docs.gitlab.com/api/merge_requests/#retrieve-a-merge-request-diff-version + const version = { + id: 91, + head_commit_sha: revision.headSha, + base_commit_sha: revision.baseSha, + start_commit_sha: revision.startSha, + state: 'collected', + real_size: '1', + }; + + describe.each([ + { selection: 'current', versionId: undefined }, + { selection: 'explicit', versionId: '91' }, + ])('$selection version completion', ({ versionId }) => { + it.each([ + { state: 'unknown', real_size: '0' }, + { state: undefined, real_size: '0' }, + { state: 'timeout', real_size: '0' }, + { state: 'empty', real_size: undefined }, + { state: 'empty', real_size: '1' }, + ])('keeps unfinished metadata retryable through both file reads: %j', async metadata => { + rows.set(`${root}/versions`, [{ ...version, ...metadata }]); + rows.set(`${root}/versions/91`, { ...version, ...metadata, diffs: [] }); + rows.set(`${root}/diffs`, []); + const selected = await auth(); + expect( + await Promise.allSettled([ + listGitLabFiles(selected, identity, revision, null, versionId), + getGitLabFileContext(selected, identity, { ...contextInput, versionId }), + ]) + ).toMatchObject([ + { status: 'rejected', reason: { code: 'temporarily_unavailable' } }, + { status: 'rejected', reason: { code: 'temporarily_unavailable' } }, + ]); + + rows.set(`${root}/versions`, [version]); + rows.set(`${root}/versions/91`, { ...version, diffs: [diff] }); + rows.set(`${root}/diffs`, [diff]); + expect( + (await listGitLabFiles(selected, identity, revision, null, versionId)).items + ).toMatchObject([{ revision, patch: diff.diff, additions: 1, deletions: 1 }]); + expect( + await getGitLabFileContext(selected, identity, { ...contextInput, versionId }) + ).toMatchObject({ revision, content: 'available', lines: ['second', 'third'] }); + }); + + it.each(['collected', 'without_files'])('reads completed %s metadata', async state => { + rows.set(`${root}/versions`, [{ ...version, state }]); + rows.set(`${root}/versions/91`, { ...version, state, diffs: [diff] }); + const selected = await auth(); + expect( + (await listGitLabFiles(selected, identity, revision, null, versionId)).items + ).toMatchObject([{ revision, patch: diff.diff, additions: 1, deletions: 1 }]); + expect( + await getGitLabFileContext(selected, identity, { ...contextInput, versionId }) + ).toMatchObject({ + revision, + content: 'available', + lines: ['second', 'third'], + canonicalUrl: `${instanceUrl}/Group/Sub/Repo/-/blob/${revision.headSha}/src/new.ts`, + }); + }); + + it.each(['collected', 'without_files', 'empty'])( + 'keeps completed zero-file %s metadata distinct from missing context', + async state => { + rows.set(`${root}/versions`, [{ ...version, state, real_size: '0' }]); + rows.set(`${root}/versions/91`, { ...version, state, real_size: '0', diffs: [] }); + rows.set(`${root}/diffs`, []); + const selected = await auth(); + expect(await listGitLabFiles(selected, identity, revision, null, versionId)).toEqual({ + items: [], + nextCursor: null, + }); + await expect( + getGitLabFileContext(selected, identity, { ...contextInput, versionId }) + ).rejects.toMatchObject({ code: 'conflict' }); + expect((await getGitLabReview(selected, repository, '7')).counts).toEqual({ + commits: 1, + files: 0, + additions: 0, + deletions: 0, + }); + } + ); + }); + + it.each([ + { state: 'overflow', real_size: '1' }, + { state: 'overflow_commits_safe_size', real_size: '1' }, + { state: 'overflow_diff_files_limit', real_size: '1' }, + { state: 'overflow_diff_lines_limit', real_size: '1' }, + { state: 'collected', real_size: '1000+' }, + ])('retains explicit-version limit errors through both file reads: %j', async metadata => { + rows.set(`${root}/versions/91`, { ...version, ...metadata, diffs: [diff] }); + const selected = await auth(); + expect( + await Promise.allSettled([ + listGitLabFiles(selected, identity, revision, null, '91'), + getGitLabFileContext(selected, identity, { ...contextInput, versionId: '91' }), + ]) + ).toMatchObject([ + { status: 'rejected', reason: { code: 'response_too_large' } }, + { status: 'rejected', reason: { code: 'response_too_large' } }, + ]); + }); + + it.each(['headSha', 'baseSha', 'startSha'] as const)( + 'rejects a mismatched explicit-version %s through both file reads', + async field => { + const stale = { ...revision, [field]: 'd'.repeat(40) }; + const selected = await auth(); + expect( + await Promise.allSettled([ + listGitLabFiles(selected, identity, stale, null, '91'), + getGitLabFileContext(selected, identity, { + ...contextInput, + file: { ...contextInput.file, revision: stale }, + versionId: '91', + }), + ]) + ).toMatchObject([ + { status: 'rejected', reason: { code: 'conflict' } }, + { status: 'rejected', reason: { code: 'conflict' } }, + ]); + } + ); + + // Both flags exclude patches: https://docs.gitlab.com/api/merge_requests/#list-merge-request-diffs + describe.each(['collapsed', 'too_large'])('%s patches', flag => { + it.each([ + { label: 'empty', patch: '' }, + { label: 'partial', patch: '@@ -1 +1 @@\n-old\n+partial' }, + ])('retains unknown counts for the $label patch beside complete files', async ({ patch }) => { + const diffs = [ + diff, + { ...diff, new_path: 'src/limited.ts', [flag]: true, diff: patch }, + { ...diff, new_path: 'src/complete.ts', diff: '@@ -0,0 +1,2 @@\n+first\n+second' }, + ]; + rows.set(`${root}/diffs`, diffs); + rows.set(`${root}/versions`, [{ ...version, real_size: '3' }]); + rows.set(`${root}/versions/91`, { ...version, real_size: '3', diffs }); + const selected = await auth(); + const [current, explicit, overview] = await Promise.all([ + listGitLabFiles(selected, identity, revision), + listGitLabFiles(selected, identity, revision, null, '91'), + getGitLabReview(selected, repository, '7'), + ]); + const expectedFiles = [ + { newPath: diff.new_path, content: 'available', additions: 1, deletions: 1 }, + { + oldPath: diff.old_path, + newPath: 'src/limited.ts', + status: 'renamed', + revision, + content: 'truncated', + patch: null, + additions: null, + deletions: null, + canonicalUrl: `${instanceUrl}/Group/Sub/Repo/-/blob/${revision.headSha}/src/limited.ts`, + }, + { newPath: 'src/complete.ts', content: 'available', additions: 2, deletions: 0 }, + ]; + expect({ + current: current.items, + explicit: explicit.items, + counts: overview.counts, + }).toMatchObject({ + current: expectedFiles, + explicit: expectedFiles, + counts: { commits: 1, files: 3, additions: null, deletions: null }, + }); + }); + }); + + it.each([null, undefined])('keeps absent patch %s counts unknown', async patch => { + rows.set(`${root}/diffs`, [{ ...diff, diff: patch }]); + const selected = await auth(); + const [files, overview] = await Promise.all([ + listGitLabFiles(selected, identity, revision), + getGitLabReview(selected, repository, '7'), + ]); + expect({ files: files.items, counts: overview.counts }).toMatchObject({ + files: [{ content: 'unavailable', additions: null, deletions: null }], + counts: { files: 1, additions: null, deletions: null }, + }); + }); + + it('sums complete patches and confirmed empty patches as numeric counts', async () => { + rows.set(`${root}/diffs`, [ + diff, + { ...diff, new_path: 'src/complete.ts', diff: '@@ -0,0 +1,2 @@\n+first\n+second' }, + { ...diff, new_path: 'src/unchanged.ts', diff: '' }, + ]); + const selected = await auth(); + expect((await listGitLabFiles(selected, identity, revision)).items).toMatchObject([ + { additions: 1, deletions: 1 }, + { additions: 2, deletions: 0 }, + { additions: 0, deletions: 0 }, + ]); + expect((await getGitLabReview(selected, repository, '7')).counts).toEqual({ + commits: 1, + files: 3, + additions: 3, + deletions: 1, + }); + }); +}); diff --git a/apps/web/src/lib/provider-review/gitlab-read.ts b/apps/web/src/lib/provider-review/gitlab-read.ts new file mode 100644 index 0000000000..77b088110d --- /dev/null +++ b/apps/web/src/lib/provider-review/gitlab-read.ts @@ -0,0 +1,1259 @@ +import 'server-only'; + +import { z } from 'zod'; +import { + repositoryResourceKey, + type RepositoryIdentity, +} from '@kilocode/app-shared/code-review/repository-identity'; +import { + ReviewActionSchema, + ReviewCapabilitiesSchema, + ReviewPositionSchema, + ReviewRevisionSchema, + REVIEW_WRITE_REQUEST_MAX_BYTES, + parseReviewCursor, + reviewPageKey, + reviewResourceKey, + type ReviewCapability, + type ReviewCursor, + type ReviewFile, + type ReviewFileContext, + type ReviewIdentity, + type ReviewInbox, + type ReviewOverview, + type ReviewPage, + type ReviewPageScope, + type ReviewRevision, + type ReviewThread, +} from '@kilocode/app-shared/provider-review'; +import { + GitLabInteractiveError, + type GitLabInteractiveResponse, +} from '@/lib/integrations/platforms/gitlab/interactive-client'; +import { + GitLabPathSchema, + GitLabUserSchema, + gitLabActor, + gitLabResourceUrl, + parseGitLab, + resolveGitLabReviewProject, + type GitLabReviewAuthorization, +} from './gitlab-authorization'; +import { MAX_GITLAB_RESPONSE_BYTES } from '@/lib/integrations/platforms/gitlab/safe-transport'; + +function bounded(value: T): T { + if (Buffer.byteLength(JSON.stringify(value), 'utf8') > MAX_GITLAB_RESPONSE_BYTES) + throw new GitLabInteractiveError('response_too_large'); + return value; +} + +const id = z.number().int().positive(); +const sha = z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i); +const refs = z.object({ head_sha: sha, base_sha: sha, start_sha: sha }); +const reviewSchema = z.object({ + id, + iid: id, + project_id: id, + target_project_id: id, + source_project_id: id.nullable(), + title: z.string(), + description: z.string().nullish(), + state: z.enum(['opened', 'closed', 'merged', 'locked']), + source_branch: z.string().nullable(), + target_branch: z.string().min(1), + draft: z.boolean().optional(), + work_in_progress: z.boolean().optional(), + sha: sha.nullish(), + diff_refs: refs.nullish(), + author: GitLabUserSchema.nullish(), + assignees: z.array(GitLabUserSchema).nullish(), + updated_at: z.string(), + web_url: z.url(), + detailed_merge_status: z.string().optional(), + has_conflicts: z.boolean().optional(), + blocking_discussions_resolved: z.boolean().optional(), + merge_when_pipeline_succeeds: z.boolean().optional(), + user: z.object({ can_merge: z.boolean().optional() }).nullish(), + head_pipeline: z.object({ id, status: z.string().optional() }).nullish(), +}); +const diffSchema = z.object({ + old_path: GitLabPathSchema, + new_path: GitLabPathSchema, + diff: z.string().nullish(), + new_file: z.boolean(), + deleted_file: z.boolean(), + renamed_file: z.boolean(), + collapsed: z.boolean().optional(), + too_large: z.boolean().optional(), + binary: z.boolean().optional(), +}); +const versionSchema = z.object({ + id, + head_commit_sha: sha, + base_commit_sha: sha, + start_commit_sha: sha, + state: z.string().optional(), + real_size: z + .string() + .regex(/^\d+\+?$/) + .optional(), +}); +const approvalSchema = z.object({ + approved: z.boolean().optional(), + approvals_required: z.number().int().nonnegative().optional(), + approvals_left: z.number().int().nonnegative().optional(), + approved_by: z.array(z.object({ user: GitLabUserSchema })), +}); +const reviewerSchema = z.object({ user: GitLabUserSchema, state: z.string() }); +const pipelineSchema = z.object({ + id, + sha, + status: z.string(), + web_url: z.string().nullish(), + name: z.string().nullish(), +}); +const statusSchema = z.object({ + id, + sha, + status: z.string(), + name: z.string(), + target_url: z.string().nullish(), + allow_failure: z.boolean().optional(), +}); +const rangeEnd = z.object({ + line_code: z.string().min(1), + type: z.enum(['old', 'new']), + old_line: id.nullish(), + new_line: id.nullish(), +}); +const positionSchema = refs.extend({ + position_type: z.enum(['text', 'image', 'file']), + old_path: z.string().nullish(), + new_path: z.string().nullish(), + old_line: id.nullish(), + new_line: id.nullish(), + line_range: z.object({ start: rangeEnd, end: rangeEnd }).nullish(), +}); +const noteSchema = z.object({ + id, + body: z.string(), + created_at: z.string(), + author: GitLabUserSchema.nullish(), + position: positionSchema.nullish(), + resolvable: z.boolean().optional(), + resolved: z.boolean().optional(), + current_user: z.object({ can_resolve: z.boolean() }).optional(), +}); +const discussionSchema = z.object({ id: z.string().min(1), notes: z.array(noteSchema).min(1) }); +const awardSchema = z.object({ id, name: z.string().min(1), user: GitLabUserSchema }); +const pageSize = 25; +const allPages = { perPage: 100, maxPages: 100 }; + +function revision(value: z.infer): ReviewRevision { + return { + headSha: value.head_sha, + baseSha: value.base_sha, + startSha: value.start_sha, + targetHeadSha: null, + }; +} +function versionRevision(value: z.infer): ReviewRevision { + return revision({ + head_sha: value.head_commit_sha, + base_sha: value.base_commit_sha, + start_sha: value.start_commit_sha, + }); +} +function sameRevision(expected: ReviewRevision, actual: ReviewRevision) { + const parsed = parseGitLab(ReviewRevisionSchema, expected, 'invalid_request'); + if ( + parsed.headSha !== actual.headSha || + parsed.baseSha !== actual.baseSha || + parsed.startSha !== actual.startSha || + parsed.targetHeadSha !== actual.targetHeadSha + ) + throw new GitLabInteractiveError('conflict'); +} +function assertIdentity(auth: GitLabReviewAuthorization, identity: ReviewIdentity) { + if ( + repositoryResourceKey(auth.userId, identity) !== + repositoryResourceKey(auth.userId, { + repository: identity.repository, + authorization: auth.authorization, + }) + ) + throw new GitLabInteractiveError('forbidden'); +} +function pagination( + auth: GitLabReviewAuthorization, + scope: ReviewPageScope, + cursor?: ReviewCursor | null +) { + const bound = { + ...scope, + resourceKey: JSON.stringify([scope.resourceKey, auth.actor.id, auth.credentialKind]), + }; + let page = 1; + if (cursor) { + try { + page = Number(parseReviewCursor(cursor, bound).token); + } catch { + throw new GitLabInteractiveError('invalid_request'); + } + } + if (!Number.isSafeInteger(page) || page < 1 || page > 100) + throw new GitLabInteractiveError('invalid_request'); + return { + options: { page, perPage: pageSize, maxPages: 1 }, + finish(items: T[], response: GitLabInteractiveResponse): ReviewPage { + const nextLink = (response.headers.link ?? '').match(/<([^>]+)>;\s*rel="next"/); + const token = + response.headers['x-next-page'] ?? + (nextLink + ? new URL(nextLink[1]).searchParams.get('page') + : items.length === pageSize + ? String(page + 1) + : null); + if (token && (Number(token) !== page + 1 || page >= 100)) + throw new GitLabInteractiveError('pagination_limit'); + return bounded>({ + items, + nextCursor: token ? { scopeKey: reviewPageKey(bound), token } : null, + }); + }, + }; +} +function completeData(response: GitLabInteractiveResponse) { + if ( + response.headers['x-next-page'] || + /rel="next"/.test(response.headers.link ?? '') || + Number(response.headers['x-total-pages'] ?? 1) > Number(response.headers['x-page'] ?? 1) + ) + throw new GitLabInteractiveError('pagination_limit'); + return response.data; +} +async function optional(operation: () => Promise>) { + try { + return completeData(await operation()); + } catch (error) { + if (error instanceof GitLabInteractiveError && ['not_found', 'forbidden'].includes(error.code)) + return null; + throw error; + } +} +async function loadReview( + auth: GitLabReviewAuthorization, + repository: RepositoryIdentity, + number: string, + expectedId?: string +) { + const iid = Number(parseGitLab(z.string().regex(/^[1-9]\d*$/), number, 'invalid_request')); + parseGitLab(id, iid, 'invalid_request'); + const project = await resolveGitLabReviewProject(auth, repository.repositoryId, repository); + const response = await project.client.execute(api => + api.MergeRequests.show(repository.repositoryId, iid) + ); + const review = parseGitLab(reviewSchema, response.data); + const canonicalUrl = `${project.canonicalUrl}/-/merge_requests/${iid}`; + if ( + String(review.project_id) !== repository.repositoryId || + String(review.target_project_id) !== repository.repositoryId || + review.iid !== iid || + (expectedId !== undefined && String(review.id) !== expectedId) || + new URL(review.web_url).toString() !== canonicalUrl + ) + throw new GitLabInteractiveError('not_found'); + if (!review.sha && !review.diff_refs) throw new GitLabInteractiveError('temporarily_unavailable'); + if (review.sha && review.diff_refs && review.sha !== review.diff_refs.head_sha) + throw new GitLabInteractiveError('temporarily_unavailable'); + const currentRevision: ReviewRevision = review.diff_refs + ? revision(review.diff_refs) + : { headSha: review.sha ?? '', baseSha: null, startSha: null, targetHeadSha: null }; + const identity: ReviewIdentity = { + repository: project.repository, + authorization: auth.authorization, + number, + reviewId: String(review.id), + canonicalUrl, + }; + return { ...project, review, identity, revision: currentRevision, iid }; +} +async function exactReview(auth: GitLabReviewAuthorization, identity: ReviewIdentity) { + assertIdentity(auth, identity); + return loadReview(auth, identity.repository, identity.number, identity.reviewId); +} +function inboxItem( + auth: GitLabReviewAuthorization, + repository: RepositoryIdentity, + review: z.infer +) { + const canonicalUrl = gitLabResourceUrl( + auth.instanceUrl, + repository.fullName, + `/-/merge_requests/${review.iid}` + ); + if ( + String(review.project_id) !== repository.repositoryId || + String(review.target_project_id) !== repository.repositoryId || + new URL(review.web_url).toString() !== canonicalUrl + ) + throw new GitLabInteractiveError('invalid_response'); + const identity: ReviewIdentity = { + repository, + authorization: auth.authorization, + reviewId: String(review.id), + number: String(review.iid), + canonicalUrl, + }; + return { + identity, + title: review.title, + author: review.author ? gitLabActor(review.author, auth.instanceUrl) : null, + state: + review.state === 'opened' + ? ('open' as const) + : review.state === 'merged' + ? ('merged' as const) + : ('closed' as const), + draft: review.draft ?? review.work_in_progress ?? false, + updatedAt: review.updated_at, + }; +} + +export async function listGitLabInbox( + auth: GitLabReviewAuthorization, + input: { + repository?: RepositoryIdentity; + filter?: 'reviewer' | 'author'; + cursor?: ReviewCursor | null; + } = {} +): Promise { + if ((auth.authorization.owner.type === 'org' || auth.projectTokenId) && !input.repository) + throw new GitLabInteractiveError('invalid_request'); + const project = input.repository + ? await resolveGitLabReviewProject(auth, input.repository.repositoryId, input.repository) + : null; + const filter = input.filter ?? 'reviewer'; + const actorScoped = project === null; + const scope = project + ? { kind: 'repository' as const, actor: auth.actor, repository: project.repository } + : { kind: 'actor' as const, actor: auth.actor }; + const page = pagination( + auth, + { + resourceKey: JSON.stringify([auth.userId, auth.authorization, auth.instanceUrl, scope]), + surface: 'inbox', + queryKey: filter, + revision: null, + }, + input.cursor + ); + const client = project?.client ?? auth.client(); + const response = await client.execute(api => + api.MergeRequests.all({ + ...page.options, + scope: 'all', + state: 'opened', + orderBy: 'updated_at', + sort: 'desc', + ...(project + ? { projectId: project.repository.repositoryId } + : filter === 'author' + ? { authorId: Number(auth.actor.id) } + : { reviewerId: Number(auth.actor.id) }), + }) + ); + const reviews = parseGitLab(z.array(reviewSchema).max(pageSize), response.data); + const items = []; + for (const review of reviews) { + const repository = + project?.repository ?? + (await resolveGitLabReviewProject(auth, String(review.project_id))).repository; + items.push(inboxItem(auth, repository, review)); + } + // Organization and project actors always retain an explicit repository scope, never a Personal label. + return { + ...page.finish(items, response), + scope: actorScoped ? { kind: 'actor', actor: auth.actor } : scope, + }; +} + +export async function listGitLabDiffVersions( + auth: GitLabReviewAuthorization, + identity: ReviewIdentity, + cursor?: ReviewCursor | null +): Promise> { + const loaded = await exactReview(auth, identity); + const page = pagination( + auth, + { + resourceKey: reviewResourceKey(auth.userId, identity), + surface: 'files', + queryKey: 'versions', + revision: null, + }, + cursor + ); + // Gitbeaker's array helper honors pagination even though this method's type omits it. + const response = await loaded.client.execute(api => + api.MergeRequests.allDiffVersions(identity.repository.repositoryId, loaded.iid, { + ...page.options, + showExpanded: false, + }) + ); + return page.finish( + parseGitLab(z.array(versionSchema).max(pageSize), response.data).map(value => ({ + id: String(value.id), + revision: versionRevision(value), + })), + response + ); +} +function fileFromDiff( + auth: GitLabReviewAuthorization, + loaded: Awaited>, + selected: ReviewRevision, + diff: z.infer +): ReviewFile { + const content = + diff.too_large || diff.collapsed + ? 'truncated' + : diff.binary || /^Binary files /.test(diff.diff ?? '') + ? 'binary' + : diff.diff + ? 'available' + : 'unavailable'; + let additions = 0, + deletions = 0, + hunk = false; + for (const line of (diff.diff ?? '').split('\n')) { + if (line.startsWith('@@')) hunk = true; + else if (hunk && line.startsWith('+')) additions++; + else if (hunk && line.startsWith('-')) deletions++; + } + return { + id: JSON.stringify([diff.old_path, diff.new_path]), + oldPath: diff.old_path, + newPath: diff.new_path, + revision: selected, + status: diff.renamed_file + ? 'renamed' + : diff.new_file + ? 'added' + : diff.deleted_file + ? 'deleted' + : 'modified', + patch: content === 'available' ? (diff.diff ?? null) : null, + content, + additions: content === 'truncated' || diff.diff == null ? null : additions, + deletions: content === 'truncated' || diff.diff == null ? null : deletions, + canonicalUrl: + diff.deleted_file || loaded.review.source_project_id === loaded.review.target_project_id + ? gitLabResourceUrl( + auth.instanceUrl, + loaded.repository.fullName, + `/-/blob/${diff.deleted_file ? selected.baseSha : selected.headSha}/${(diff.deleted_file ? diff.old_path : diff.new_path).split('/').map(encodeURIComponent).join('/')}` + ) + : `${loaded.identity.canonicalUrl}/diffs`, + }; +} +function requireCompleteDiffVersion(version: z.infer | undefined) { + // A terminal diff page does not prove completeness when GitLab applies its diff limits. + if (version?.state?.startsWith('overflow') || version?.real_size?.endsWith('+')) + throw new GitLabInteractiveError('response_too_large'); + if ( + !version || + (!['collected', 'without_files'].includes(version.state ?? '') && + !(version.state === 'empty' && version.real_size === '0')) + ) + throw new GitLabInteractiveError('temporarily_unavailable'); + return version; +} +async function versionDiffs( + loaded: Awaited>, + selected: ReviewRevision, + versionId: string +) { + const versionNumber = parseGitLab( + id, + Number(parseGitLab(z.string().regex(/^[1-9]\d*$/), versionId, 'invalid_request')), + 'invalid_request' + ); + const response = await loaded.client.execute(api => + api.MergeRequests.showDiffVersion(loaded.repository.repositoryId, loaded.iid, versionNumber) + ); + const version = parseGitLab(versionSchema.extend({ diffs: z.array(diffSchema) }), response.data); + if (String(version.id) !== versionId) throw new GitLabInteractiveError('invalid_response'); + sameRevision(selected, versionRevision(version)); + requireCompleteDiffVersion(version); + return version.diffs; +} +async function currentDiffs( + loaded: Awaited>, + options: typeof allPages & { page?: number } +) { + const versions = parseGitLab( + z.array(versionSchema), + completeData( + await loaded.client.execute(api => + api.MergeRequests.allDiffVersions(loaded.repository.repositoryId, loaded.iid) + ) + ) + ); + const current = requireCompleteDiffVersion( + versions.find( + value => + value.head_commit_sha === loaded.revision.headSha && + value.base_commit_sha === loaded.revision.baseSha && + value.start_commit_sha === loaded.revision.startSha + ) + ); + const response = await loaded.client.execute(api => + api.MergeRequests.allDiffs(loaded.repository.repositoryId, loaded.iid, options) + ); + const diffs = parseGitLab( + z.array(diffSchema).max(options.perPage * options.maxPages), + options.maxPages === 1 ? response.data : completeData(response) + ); + const terminal = + options.maxPages !== 1 || + response.headers['x-next-page'] === '' || + (!response.headers['x-next-page'] && + !/rel="next"/.test(response.headers.link ?? '') && + diffs.length < options.perPage); + const received = ((options.page ?? 1) - 1) * options.perPage + diffs.length; + if (terminal && current.real_size !== undefined && Number(current.real_size) > received) + throw new GitLabInteractiveError('response_too_large'); + return { response, diffs }; +} +export async function listGitLabFiles( + auth: GitLabReviewAuthorization, + identity: ReviewIdentity, + selected: ReviewRevision, + cursor?: ReviewCursor | null, + versionId?: string +): Promise> { + const loaded = await exactReview(auth, identity); + if (!versionId && !loaded.review.diff_refs) + throw new GitLabInteractiveError('temporarily_unavailable'); + const page = pagination( + auth, + { + resourceKey: reviewResourceKey(auth.userId, identity), + surface: 'files', + queryKey: versionId ?? 'current', + revision: selected, + }, + cursor + ); + if (versionId) { + const diffs = await versionDiffs(loaded, selected, versionId); + const start = (page.options.page - 1) * pageSize; + return page.finish( + diffs.slice(start, start + pageSize).map(diff => fileFromDiff(auth, loaded, selected, diff)), + { + status: 200, + data: null, + headers: { + 'x-next-page': start + pageSize < diffs.length ? String(page.options.page + 1) : '', + }, + } + ); + } + sameRevision(selected, loaded.revision); + const { response, diffs } = await currentDiffs(loaded, page.options); + sameRevision(selected, (await exactReview(auth, identity)).revision); + return page.finish( + diffs.map(diff => fileFromDiff(auth, loaded, selected, diff)), + response + ); +} + +export async function getGitLabFileContext( + auth: GitLabReviewAuthorization, + identity: ReviewIdentity, + input: { + file: Pick; + side: 'old' | 'new'; + startLine: number; + lineCount: number; + versionId?: string; + } +): Promise { + parseGitLab( + z.object({ side: z.enum(['old', 'new']), startLine: id, lineCount: id.max(500) }), + input, + 'invalid_request' + ); + const loaded = await exactReview(auth, identity); + const selected = parseGitLab(ReviewRevisionSchema, input.file.revision, 'invalid_request'); + let diffs; + if (input.versionId) diffs = await versionDiffs(loaded, selected, input.versionId); + else { + sameRevision(selected, loaded.revision); + diffs = (await currentDiffs(loaded, allPages)).diffs; + sameRevision(selected, (await exactReview(auth, identity)).revision); + } + const files = diffs.map(diff => fileFromDiff(auth, loaded, selected, diff)); + if ( + !files.some(file => file.oldPath === input.file.oldPath && file.newPath === input.file.newPath) + ) + throw new GitLabInteractiveError('conflict'); + const filePath = input.side === 'old' ? input.file.oldPath : input.file.newPath; + const commit = input.side === 'old' ? selected.baseSha : selected.headSha; + if (!filePath || !commit) throw new GitLabInteractiveError('invalid_request'); + parseGitLab(sha, commit, 'invalid_request'); + const result = bounded({ + revision: selected, + path: filePath, + side: input.side, + startLine: input.startLine, + lines: [], + totalLines: null, + content: 'unavailable', + canonicalUrl: loaded.identity.canonicalUrl, + }); + try { + const sourceId = + input.side === 'new' ? loaded.review.source_project_id : loaded.review.target_project_id; + if (sourceId === null) return result; + const project = + String(sourceId) === loaded.repository.repositoryId + ? loaded + : await resolveGitLabReviewProject(auth, String(sourceId)); + result.canonicalUrl = gitLabResourceUrl( + auth.instanceUrl, + project.repository.fullName, + `/-/blob/${commit}/${filePath.split('/').map(encodeURIComponent).join('/')}` + ); + const response = await project.client.execute(api => + api.RepositoryFiles.show(String(sourceId), filePath, commit) + ); + const file = parseGitLab( + z.object({ + file_path: z.string(), + commit_id: sha, + encoding: z.literal('base64'), + content: z.string(), + size: z.number().int().nonnegative(), + }), + response.data + ); + if (file.file_path !== filePath || file.commit_id !== commit) + throw new GitLabInteractiveError('conflict'); + const encoded = file.content.replace(/\s/g, ''); + const bytes = Buffer.from(encoded, 'base64'); + if (bytes.toString('base64') !== encoded) throw new GitLabInteractiveError('invalid_response'); + if (bytes.length !== file.size) return { ...result, content: 'truncated' }; + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + return { ...result, content: 'binary' }; + } + if (bytes.includes(0)) return { ...result, content: 'binary' }; + const lines = text === '' ? [] : text.replace(/\r?\n$/, '').split(/\r?\n/); + return bounded({ + ...result, + content: 'available', + lines: lines.slice(input.startLine - 1, input.startLine - 1 + input.lineCount), + totalLines: lines.length, + }); + } catch (error) { + if (error instanceof GitLabInteractiveError && error.code === 'response_too_large') + return { ...result, content: 'truncated' }; + if (error instanceof GitLabInteractiveError && ['not_found', 'forbidden'].includes(error.code)) + return result; + throw error; + } +} + +function checkState( + status: string +): 'pending' | 'running' | 'passed' | 'failed' | 'skipped' | 'cancelled' | 'unknown' { + switch (status) { + case 'created': + case 'waiting_for_resource': + case 'preparing': + case 'pending': + case 'manual': + case 'scheduled': + return 'pending'; + case 'running': + return 'running'; + case 'success': + return 'passed'; + case 'failed': + return 'failed'; + case 'skipped': + return 'skipped'; + case 'canceled': + return 'cancelled'; + default: + return 'unknown'; + } +} +function detailsUrl(value?: string | null) { + return z.url({ protocol: /^https$/ }).safeParse(value).success ? (value ?? null) : null; +} +async function checksFor( + loaded: Awaited> +): Promise { + const pipelines = await optional(() => + loaded.client.execute(api => + api.MergeRequests.allPipelines(loaded.repository.repositoryId, loaded.iid, allPages) + ) + ); + // This method also uses Gitbeaker's paginated array helper despite its narrower option type. + const statuses = await optional(() => + loaded.client.execute(api => + api.Commits.allStatuses(loaded.repository.repositoryId, loaded.revision.headSha, { + ...allPages, + showExpanded: false, + }) + ) + ); + if (pipelines === null || statuses === null) + return { status: 'unavailable', explanation: 'forbidden_or_unavailable' }; + const checks = [ + ...parseGitLab(z.array(pipelineSchema), pipelines) + .filter( + value => + value.sha === loaded.revision.headSha || value.id === loaded.review.head_pipeline?.id + ) + .map(value => ({ + id: `pipeline:${value.id}`, + name: value.name || `#${value.id}`, + state: checkState(value.status), + required: + value.id === loaded.review.head_pipeline?.id + ? (loaded.project.only_allow_merge_if_pipeline_succeeds ?? null) + : false, + detailsUrl: detailsUrl(value.web_url), + })), + ...parseGitLab(z.array(statusSchema), statuses) + .filter(value => value.sha === loaded.revision.headSha) + .map(value => ({ + id: `status:${value.id}`, + name: value.name, + state: checkState(value.status), + required: value.allow_failure === undefined ? null : !value.allow_failure, + detailsUrl: detailsUrl(value.target_url), + })), + ]; + return checks.length ? { status: 'reported', checks } : { status: 'none', checks: [] }; +} +export async function getGitLabChecks( + auth: GitLabReviewAuthorization, + identity: ReviewIdentity, + selected?: ReviewRevision +): Promise { + const loaded = await exactReview(auth, identity); + if (selected) sameRevision(selected, loaded.revision); + const checks = await checksFor(loaded); + sameRevision(loaded.revision, (await exactReview(auth, identity)).revision); + return bounded(checks); +} + +export async function getGitLabReview( + auth: GitLabReviewAuthorization, + repository: RepositoryIdentity, + number: string +): Promise { + const loaded = await loadReview(auth, repository, number); + const { review, client, iid } = loaded; + const metadataData = await optional(() => client.execute(api => api.Metadata.show())); + const metadata = + metadataData === null + ? null + : parseGitLab( + z.object({ version: z.string(), enterprise: z.boolean().optional() }), + metadataData + ); + const approvalData = await optional(() => + client.execute(api => + api.MergeRequestApprovals.showConfiguration(repository.repositoryId, { mergerequestIId: iid }) + ) + ); + const approvals = approvalData === null ? null : parseGitLab(approvalSchema, approvalData); + // The approval state lists eligible users; the approvals endpoint lists every approving user. + const approvalStateData = await optional(() => + client.execute(api => api.MergeRequestApprovals.showApprovalState(repository.repositoryId, iid)) + ); + const approvalState = + approvalStateData === null + ? null + : parseGitLab( + z.object({ + rules: z.array(z.object({ eligible_approvers: z.array(GitLabUserSchema).optional() })), + }), + approvalStateData + ); + const canApprove = approvalState?.rules.some(rule => + rule.eligible_approvers?.some(user => String(user.id) === auth.actor.id) + ) + ? true + : undefined; + const hasApproved = approvals?.approved_by.some(item => String(item.user.id) === auth.actor.id); + const reviewerData = await optional(() => + client.execute(api => api.MergeRequests.showReviewers(repository.repositoryId, iid)) + ); + const reviewers = + reviewerData === null ? null : parseGitLab(z.array(reviewerSchema), reviewerData); + const checks = await checksFor(loaded); + const { diffs } = await currentDiffs(loaded, allPages); + const files = diffs.map(diff => fileFromDiff(auth, loaded, loaded.revision, diff)); + const commits = parseGitLab( + z.array(z.object({ id: sha })), + completeData( + await client.execute(api => + api.MergeRequests.allCommits(repository.repositoryId, iid, allPages) + ) + ) + ); + sameRevision( + loaded.revision, + (await loadReview(auth, repository, number, loaded.identity.reviewId)).revision + ); + let source: RepositoryIdentity | null = + review.source_project_id === review.target_project_id ? loaded.repository : null; + if (!source && review.source_project_id !== null) { + try { + source = (await resolveGitLabReviewProject(auth, String(review.source_project_id))) + .repository; + } catch (error) { + if ( + !(error instanceof GitLabInteractiveError) || + !['not_found', 'forbidden'].includes(error.code) + ) + throw error; + } + } + const version = metadata?.version.match(/^(\d+)\.(\d+)\./); + const modernAutoMerge = version + ? Number(version[1]) > 17 || (Number(version[1]) === 17 && Number(version[2]) >= 11) + : null; + const writable = + auth.scopes === null ? 'unknown' : auth.scopes.includes('api') ? 'allowed' : 'forbidden'; + const access = Math.max( + loaded.project.permissions?.project_access?.access_level ?? 0, + loaded.project.permissions?.group_access?.access_level ?? 0 + ); + const currentPipelineState = + review.head_pipeline?.status !== undefined + ? checkState(review.head_pipeline.status) + : checks.status === 'reported' + ? checks.checks.find(check => check.id === `pipeline:${review.head_pipeline?.id}`)?.state + : undefined; + const restrictions = [ + ...(review.state !== 'opened' ? [review.state] : []), + ...(review.draft || review.work_in_progress ? ['draft'] : []), + ...(review.has_conflicts ? ['conflict'] : []), + ...(review.detailed_merge_status && + review.detailed_merge_status !== 'mergeable' && + review.detailed_merge_status !== 'can_be_merged' + ? [review.detailed_merge_status] + : []), + ...(loaded.project.only_allow_merge_if_all_discussions_are_resolved && + review.blocking_discussions_resolved !== true + ? ['discussions_not_resolved'] + : []), + ...(loaded.project.only_allow_merge_if_pipeline_succeeds && + currentPipelineState !== 'passed' && + !(currentPipelineState === 'skipped' && loaded.project.allow_merge_on_skipped_pipeline) + ? ['pipeline_not_successful'] + : []), + ...(!loaded.project.merge_method ? ['merge_method_unknown'] : []), + ]; + const capability: ReviewCapability = { + support: 'supported', + version: 'available', + license: 'available', + permission: writable, + restrictions: [], + explanation: '', + evidenceUrl: 'https://docs.gitlab.com/api/merge_requests/', + recovery: + writable === 'forbidden' ? 'reconnect' : writable === 'unknown' ? 'openProvider' : 'none', + expectedHeadProtection: 'none', + }; + const permission = (allowed?: boolean): ReviewCapability['permission'] => + writable !== 'allowed' + ? writable + : allowed === undefined + ? 'unknown' + : allowed + ? 'allowed' + : 'forbidden'; + const capabilities = ReviewCapabilitiesSchema.parse( + Object.fromEntries( + ReviewActionSchema.options.map(action => { + const value: ReviewCapability = { + ...capability, + permission: permission(access > 0 ? true : undefined), + }; + if (action === 'read') { + value.permission = 'allowed'; + value.recovery = 'none'; + } + if (action === 'merge' || action === 'enableAutoMerge' || action === 'disableAutoMerge') { + value.permission = permission(review.user?.can_merge); + value.restrictions = + action === 'disableAutoMerge' + ? review.merge_when_pipeline_succeeds + ? [] + : ['auto_merge_not_enabled'] + : action === 'enableAutoMerge' + ? restrictions.filter( + reason => + ![ + 'pipeline_not_successful', + 'ci_still_running', + ...(modernAutoMerge + ? ['not_approved', 'requested_changes', 'discussions_not_resolved'] + : []), + ].includes(reason) + ) + : restrictions; + } + if (action === 'approve') value.permission = permission(canApprove); + if (action === 'unapprove') { + value.permission = permission(true); + value.restrictions = + hasApproved === undefined + ? ['approval_state_unknown'] + : hasApproved + ? [] + : ['not_approved']; + value.explanation = value.restrictions[0] ?? ''; + if (hasApproved === undefined) value.recovery = 'refresh'; + } + if (action === 'approve' || action === 'unapprove') + value.evidenceUrl = 'https://docs.gitlab.com/api/merge_request_approvals/'; + if ( + [ + 'deleteBranch', + 'updateBranch', + 'removeChangeRequest', + 'resolveThread', + 'reopenThread', + ].includes(action) + ) + value.permission = permission(); + if (action === 'inlineComment') value.expectedHeadProtection = 'revisionAttachment'; + if (action === 'merge' || action === 'approve') + value.expectedHeadProtection = 'atomicSource'; + if (action === 'requestChanges') { + const assigned = reviewers?.some(item => String(item.user.id) === auth.actor.id); + // update_merge_request permits Developers, authors, and assignees of readable reviews. + const canUpdate = + access >= 30 || + String(review.author?.id) === auth.actor.id || + review.assignees?.some(user => String(user.id) === auth.actor.id) + ? true + : undefined; + value.permission = + assigned === false ? 'forbidden' : permission(assigned ? canUpdate : undefined); + value.version = reviewers?.some(item => item.state === 'requested_changes') + ? 'available' + : 'unknown'; + value.explanation = value.version === 'unknown' ? 'review_state_support_unknown' : ''; + value.evidenceUrl = + 'https://docs.gitlab.com/api/graphql/reference/#mutationmergerequestrequestchanges'; + } + if (action === 'enableAutoMerge' || action === 'disableAutoMerge') { + value.version = modernAutoMerge === null ? 'unknown' : 'available'; + // Old instances use merge_when_pipeline_succeeds. Remove this legacy form only after + // pre-17.11 instances and old clients/records disappear and the 30-day ledger window expires. + value.explanation = + modernAutoMerge === null + ? 'version_unknown' + : modernAutoMerge + ? 'auto_merge' + : 'merge_when_pipeline_succeeds'; + } + if (value.version === 'unknown') value.recovery = 'openProvider'; + if (value.permission !== 'allowed' && !value.explanation) + value.explanation = value.permission; + if (value.permission !== 'allowed') + value.recovery = + value.permission === 'forbidden' && writable === 'forbidden' + ? 'reconnect' + : 'openProvider'; + return [action, value]; + }) + ) + ); + const blocksMerge = + review.detailed_merge_status === 'requested_changes' + ? true + : metadata?.enterprise === false + ? false + : null; + const { identity, title, author, state, draft } = inboxItem(auth, loaded.repository, review); + return bounded({ + identity, + title, + author, + state, + draft, + bodyMarkdown: review.description ?? null, + revision: loaded.revision, + source: { repository: source, branch: review.source_branch }, + target: { repository: loaded.repository, branch: review.target_branch }, + authorization: { + actor: auth.actor, + credentialKind: auth.credentialKind, + capabilities, + writeLimits: { requestMaxBytes: REVIEW_WRITE_REQUEST_MAX_BYTES, bodyMaxBytes: null }, + }, + providerState: { + provider: 'gitlab', + approvals: { + approved: approvals?.approved ?? null, + required: approvals?.approvals_required ?? null, + remaining: approvals?.approvals_left ?? null, + actorIds: approvals?.approved_by.map(item => String(item.user.id)) ?? [], + }, + requestedChanges: { + actorIds: + reviewers + ?.filter(item => item.state === 'requested_changes') + .map(item => String(item.user.id)) ?? [], + blocksMerge, + blockingCapability: { + ...capability, + version: blocksMerge === true ? 'available' : 'unknown', + license: + blocksMerge === true + ? 'available' + : metadata?.enterprise === false + ? 'unavailable' + : 'unknown', + permission: 'allowed', + explanation: blocksMerge === null ? 'license_or_feature_flag_unknown' : '', + recovery: blocksMerge === null ? 'openProvider' : 'none', + evidenceUrl: + 'https://docs.gitlab.com/user/project/merge_requests/reviews/#request-changes', + }, + }, + }, + checks, + counts: { + commits: commits.length, + files: files.length, + additions: files.reduce( + (total, file) => + total === null || file.additions === null ? null : total + file.additions, + 0 + ), + deletions: files.reduce( + (total, file) => + total === null || file.deletions === null ? null : total + file.deletions, + 0 + ), + }, + merge: { + methods: loaded.project.merge_method + ? [{ id: loaded.project.merge_method, label: loaded.project.merge_method }] + : [], + squash: + loaded.project.squash_option === 'always' + ? 'required' + : loaded.project.squash_option === 'never' + ? 'forbidden' + : loaded.project.squash_option + ? 'optional' + : null, + autoMerge: review.merge_when_pipeline_succeeds + ? { method: loaded.project.merge_method ?? 'unknown' } + : null, + task: null, + }, + }); +} + +export async function listGitLabDiscussions( + auth: GitLabReviewAuthorization, + identity: ReviewIdentity, + cursor?: ReviewCursor | null +): Promise> { + const loaded = await exactReview(auth, identity); + const page = pagination( + auth, + { + resourceKey: reviewResourceKey(auth.userId, identity), + surface: 'threads', + queryKey: 'all', + revision: loaded.revision, + }, + cursor + ); + const response = await loaded.client.execute(api => + api.MergeRequestDiscussions.all(loaded.repository.repositoryId, loaded.iid, page.options) + ); + const discussions = parseGitLab(z.array(discussionSchema).max(pageSize), response.data); + if (discussions.reduce((count, discussion) => count + discussion.notes.length, 0) > 100) + throw new GitLabInteractiveError('response_too_large'); + const items: ReviewThread[] = []; + for (const discussion of discussions) { + const first = discussion.notes[0]; + const resolvableNotes = discussion.notes.filter(note => note.resolvable !== false); + const resolved = resolvableNotes.some(note => note.resolvable && note.resolved === false) + ? false + : resolvableNotes.length > 0 && + resolvableNotes.every(note => note.resolvable && note.resolved === true) + ? true + : null; + const native = first.position; + const file = native + ? { + oldPath: native.old_path ?? null, + newPath: native.new_path ?? null, + revision: revision(native), + } + : null; + const mapEnd = (end: z.infer) => ({ + lineCode: end.line_code, + side: end.type, + oldLine: end.old_line ?? null, + newLine: end.new_line ?? null, + }); + const position = + native?.position_type === 'text' + ? parseGitLab(ReviewPositionSchema, { + ...file, + side: native.line_range?.end.type ?? (native.new_line ? 'new' : 'old'), + line: native.line_range + ? native.line_range.end.type === 'old' + ? native.line_range.end.old_line + : native.line_range.end.new_line + : (native.new_line ?? native.old_line), + ...(native.line_range + ? { + startSide: native.line_range.start.type, + startLine: + native.line_range.start.type === 'old' + ? native.line_range.start.old_line + : native.line_range.start.new_line, + } + : {}), + native: { + provider: 'gitlab', + oldLine: native.old_line ?? null, + newLine: native.new_line ?? null, + ...(native.line_range + ? { + lineRange: { + start: mapEnd(native.line_range.start), + end: mapEnd(native.line_range.end), + }, + } + : {}), + }, + }) + : null; + const comments = []; + for (const note of discussion.notes) { + const awards = parseGitLab( + z.array(awardSchema), + completeData( + await loaded.client.execute(api => + api.MergeRequestNoteAwardEmojis.all( + loaded.repository.repositoryId, + loaded.iid, + note.id, + allPages + ) + ) + ) + ); + const reactions = new Map< + string, + { id: string; content: string; count: number; viewerHasReacted: boolean } + >(); + for (const award of awards) { + const own = String(award.user.id) === auth.actor.id; + const old = reactions.get(award.name); + reactions.set(award.name, { + id: own ? String(award.id) : (old?.id ?? String(award.id)), + content: award.name, + count: (old?.count ?? 0) + 1, + viewerHasReacted: own || old?.viewerHasReacted === true, + }); + } + comments.push({ + id: String(note.id), + reference: { + provider: 'gitlab' as const, + kind: 'comment' as const, + id: String(note.id), + url: `${loaded.identity.canonicalUrl}#note_${note.id}`, + }, + author: note.author ? gitLabActor(note.author, auth.instanceUrl) : null, + bodyMarkdown: note.body, + createdAt: note.created_at, + reactions: [...reactions.values()], + }); + bounded(comments); + } + // GitLab permits Developers, Maintainers, Owners, and the merge request author to resolve threads. + const isAuthor = String(loaded.review.author?.id) === auth.actor.id; + const canResolve = + first.current_user?.can_resolve ?? + (isAuthor + ? true + : loaded.project.permissions + ? Math.max( + loaded.project.permissions.project_access?.access_level ?? 0, + loaded.project.permissions.group_access?.access_level ?? 0 + ) >= 30 + : undefined); + const missingGrant = auth.scopes !== null && !auth.scopes.includes('api'); + const resolveCapability: ReviewCapability = { + support: 'supported', + version: 'available', + license: 'available', + permission: missingGrant + ? 'forbidden' + : auth.scopes === null || canResolve === undefined + ? 'unknown' + : canResolve + ? 'allowed' + : 'forbidden', + restrictions: first.resolvable ? [] : ['not_resolvable'], + explanation: first.resolvable ? '' : 'not_resolvable', + recovery: missingGrant + ? 'reconnect' + : auth.scopes === null || canResolve !== true + ? 'openProvider' + : 'none', + evidenceUrl: 'https://docs.gitlab.com/api/discussions/', + expectedHeadProtection: 'none', + }; + items.push({ + id: discussion.id, + reference: { + provider: 'gitlab', + kind: 'thread', + id: discussion.id, + url: `${loaded.identity.canonicalUrl}#note_${first.id}`, + }, + subjectType: position ? 'line' : file ? 'file' : 'conversation', + file, + position, + diffHunk: null, + resolved, + outdated: native + ? native.head_sha !== loaded.revision.headSha || + native.base_sha !== loaded.revision.baseSha || + native.start_sha !== loaded.revision.startSha + : null, + comments: { items: comments, nextCursor: null }, + capabilities: { resolveThread: resolveCapability, reopenThread: resolveCapability }, + }); + bounded(items); + } + sameRevision(loaded.revision, (await exactReview(auth, identity)).revision); + return page.finish(items, response); +}