diff --git a/apps/web/src/lib/provider-review/gitlab-write.test.ts b/apps/web/src/lib/provider-review/gitlab-write.test.ts new file mode 100644 index 0000000000..79bb9aafab --- /dev/null +++ b/apps/web/src/lib/provider-review/gitlab-write.test.ts @@ -0,0 +1,1313 @@ +jest.mock('@/lib/drizzle', () => ({ db: {} })); +jest.mock('@/lib/integrations/gitlab-service', () => ({ getGitLabIntegration: jest.fn() })); +jest.mock('@/lib/integrations/platforms/gitlab/credential-broker-client', () => ({ + fetchGitLabCredential: jest.fn(), +})); +jest.mock('./gitlab-read', () => ({ getGitLabReview: jest.fn(), listGitLabFiles: jest.fn() })); +jest.mock('./operation', () => ({ + ...jest.requireActual('./operation'), + runReviewOperation: jest.fn(), +})); + +import { buildSchema, graphql } from 'graphql'; +import { + createGitLabInteractiveClient, + GitLabInteractiveError, +} from '@/lib/integrations/platforms/gitlab/interactive-client'; +import { fetchGitLabCredential } from '@/lib/integrations/platforms/gitlab/credential-broker-client'; +import { repositoryResourceKey } from '@kilocode/app-shared/code-review/repository-identity'; +import { + providerReviewIntentFingerprint, + type ReviewIntentInput, + type ReviewOverview, + type ReviewPosition, +} from '@kilocode/app-shared/provider-review'; +import { reviewCapabilityFixtures } from '@kilocode/app-shared/provider-review/fixtures'; +import type { GitLabReviewAuthorization } from './gitlab-authorization'; +import { getGitLabReview, listGitLabFiles } from './gitlab-read'; +import { + rejectedReviewEffect, + reviewEffectOperationKey, + runReviewOperation, + type ReviewEffectResult, + type ReviewOperationRequest, +} from './operation'; +import { runGitLabReviewOperation } from './gitlab-write'; + +const userId = 'oauth/caller', + instanceUrl = 'https://gitlab.com/GitLab'; +const authorization = { + kind: 'ownerIntegration' as const, + integrationId: '11111111-1111-4111-8111-111111111111', + owner: { type: 'user' as const, id: userId }, +}; +const repository = { + provider: 'gitlab' as const, + instanceUrl, + repositoryId: '123', + fullName: 'Group/Sub/Repo', + defaultBranch: 'trunk', +}; +const identity = { + repository, + authorization, + reviewId: '77', + number: '7', + canonicalUrl: `${instanceUrl}/${repository.fullName}/-/merge_requests/7`, +}; +const revision = { + headSha: 'a'.repeat(40), + baseSha: 'b'.repeat(40), + startSha: 'c'.repeat(40), + targetHeadSha: null, +}; +const position: ReviewPosition = { + revision, + oldPath: 'old.ts', + newPath: 'new.ts', + side: 'new', + line: 3, + startSide: 'new', + startLine: 2, + native: { + provider: 'gitlab', + oldLine: null, + newLine: 3, + lineRange: { + start: { lineCode: 'line-2', side: 'new', oldLine: null, newLine: 2 }, + end: { lineCode: 'line-3', side: 'new', oldLine: null, newLine: 3 }, + }, + }, +}; +const nativePosition = { + position_type: 'text', + head_sha: revision.headSha, + base_sha: revision.baseSha, + start_sha: revision.startSha, + old_path: 'old.ts', + new_path: 'new.ts', + new_line: 3, + old_line: null, + line_range: { + start: { line_code: 'line-2', type: 'new', new_line: 2 }, + end: { line_code: 'line-3', type: 'new', new_line: 3 }, + }, +}; +const root = '/projects/123/merge_requests/7'; +const actor = { id: 9, username: 'integration-actor' }; +const note = { + id: 8, + body: 'Original', + author: actor, + noteable_id: 77, + resolvable: true, + resolved: false, + current_user: { can_resolve: true }, +}; +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 baseReview = { + id: 77, + iid: 7, + project_id: 123, + target_project_id: 123, + source_project_id: 123, + web_url: identity.canonicalUrl, + state: 'opened', + squash: false, + user: { can_merge: true }, + sha: revision.headSha, + diff_refs: { + head_sha: revision.headSha, + base_sha: revision.baseSha, + start_sha: revision.startSha, + }, + source_branch: 'feature', + target_branch: 'trunk', + merge_when_pipeline_succeeds: false, + rebase_in_progress: false, + merge_error: null as string | null, +}; +let auth: GitLabReviewAuthorization; +let review: typeof baseReview; +let overview: ReviewOverview; +let notes: (typeof note)[]; +let awards: { id: number; name: string; user: typeof actor }[]; +let writes: { path: string; body: any; method: string }[]; +let failures: Map; +let version: string; +let branch: { + name: string; + default: boolean; + protected: boolean; + can_push: boolean; + commit: { id: string }; +}; +let branchExists: boolean; +let drafts: Set; +let draftDiscussion: { discussion_id: string | null; resolve_discussion: boolean }; +let requestedChanges: boolean; +let loseResponse: string | undefined; +let acceptedResponse: string | undefined; +let failReadAfterWrite: boolean; +let beforeWrite: (() => void) | undefined; +let providerActor: typeof actor; +let records: Map; +const request = (input: ReviewIntentInput): ReviewOperationRequest => ({ + userId, + distinctId: 'caller@example.com', + operationKey: '22222222-2222-4222-8222-222222222222', + intent: { accountId: userId, actorId: '9', review: identity, revision, input }, +}); +const run = (input: ReviewIntentInput) => runGitLabReviewOperation(auth, request(input)); +const graph = + buildSchema(`type Query { unused: Boolean } input MergeRequestRequestChangesInput { projectPath: ID!, iid: String! } + type MergeRequest { id: ID!, iid: String! } type Payload { mergeRequest: MergeRequest, errors: [String!]! } + type Mutation { mergeRequestRequestChanges(input: MergeRequestRequestChangesInput!): Payload }`); + +beforeEach(() => { + jest.resetAllMocks(); + review = structuredClone(baseReview); + project.merge_method = 'ff'; + project.squash_option = 'always'; + notes = [structuredClone(note)]; + awards = []; + writes = []; + failures = new Map(); + version = '17.11.0'; + branch = { + name: 'feature', + default: false, + protected: false, + can_push: true, + commit: { id: revision.headSha }, + }; + branchExists = true; + drafts = new Set(['11']); + draftDiscussion = { discussion_id: null, resolve_discussion: false }; + requestedChanges = false; + loseResponse = undefined; + acceptedResponse = undefined; + failReadAfterWrite = false; + beforeWrite = undefined; + providerActor = actor; + records = new Map(); + auth = { + userId, + authorization, + instanceUrl, + actor: { + provider: 'gitlab', + instanceUrl, + id: '9', + login: actor.username, + displayName: null, + avatarUrl: null, + }, + credentialKind: 'gitlabPat', + scopes: ['api'], + projectTokenId: undefined, + client: projectId => + createGitLabInteractiveClient({ + actor: { userId }, + selector: { credential: 'integration', integrationId: authorization.integrationId }, + instanceUrl, + scope: projectId ? { kind: 'project', projectId } : { kind: 'discovery' }, + }), + }; + overview = { + identity, + title: 'Review', + bodyMarkdown: null, + author: auth.actor, + state: 'open', + draft: false, + revision, + source: { repository, branch: 'feature' }, + target: { repository, branch: 'trunk' }, + authorization: { + actor: auth.actor, + credentialKind: auth.credentialKind, + capabilities: reviewCapabilityFixtures('gitlab'), + writeLimits: { requestMaxBytes: 256_000, bodyMaxBytes: null }, + }, + providerState: { + provider: 'gitlab', + approvals: { approved: false, required: 0, remaining: 0, actorIds: [] }, + requestedChanges: { + actorIds: [], + blocksMerge: false, + blockingCapability: { + ...reviewCapabilityFixtures('gitlab').requestChanges, + license: 'unavailable', + }, + }, + }, + checks: { status: 'none', checks: [] }, + counts: { commits: 1, files: 1, additions: 3, deletions: 1 }, + merge: { + methods: [{ id: 'ff', label: 'ff' }], + squash: 'required', + autoMerge: null, + task: null, + }, + }; + jest.mocked(getGitLabReview).mockImplementation(async () => ({ + ...overview, + state: review.state === 'merged' ? 'merged' : overview.state, + revision: { ...revision, headSha: review.sha }, + })); + jest.mocked(listGitLabFiles).mockResolvedValue({ + items: [ + { + id: 'file', + oldPath: 'old.ts', + newPath: 'new.ts', + revision, + status: 'renamed', + patch: '@@ -1,3 +1,3 @@', + content: 'available', + additions: 3, + deletions: 1, + canonicalUrl: identity.canonicalUrl, + }, + ], + nextCursor: null, + }); + jest.mocked(fetchGitLabCredential).mockResolvedValue({ + status: 'available', + token: 'fixture-token', + instanceUrl, + glabIsOAuth2: false, + }); + // The ledger suite tests admission/CAS. This fake preserves its contract for protocol and batch tests. + jest.mocked(runReviewOperation).mockImplementation(async (input, handlers) => { + const key = reviewEffectOperationKey(input.operationKey, input.effect?.id), + fingerprint = providerReviewIntentFingerprint(input.intent), + old = records.get(key); + if (old && old.fingerprint !== fingerprint) + return rejectedReviewEffect('operation_key_reuse_mismatch'); + if ( + old?.result?.status === 'confirmed' || + (old?.result?.status === 'rejected' && old.result.retry === 'never') + ) + return old.result; + if (!old && !handlers.execute) + return rejectedReviewEffect('operation_not_admitted', 'same-key'); + const value = old ?? { fingerprint }; + records.set(key, value); + const result = + handlers.execute && + (!old || (old.result?.status === 'rejected' && old.result.retry === 'same-key')) + ? await handlers.execute() + : await handlers.reconcile(old?.result ?? null); + // An unavailable status read does not erase durable acceptance evidence. + if (value.result?.status !== 'accepted' || result.status !== 'unresolved') + value.result = result; + return result; + }); + global.fetch = jest.fn(async (destination, init) => { + const url = new URL(String(destination)), + path = url.pathname.replace('/GitLab/api/v4', ''); + const method = init?.method ?? 'GET'; + if (method === 'GET') { + if (failReadAfterWrite && writes.length) return Response.json({}, { status: 403 }); + if (path === '/metadata') return Response.json({ version, enterprise: false }); + if (path === '/projects/123') return Response.json(project); + if (path === root) return Response.json(review); + if (path.startsWith(`${root}/notes/`) && /^\d+$/.test(path.slice(`${root}/notes/`.length))) { + const found = notes.find(item => String(item.id) === path.slice(`${root}/notes/`.length)); + return Response.json(found ?? {}, { status: found ? 200 : 404 }); + } + if (path === `${root}/approvals`) + return Response.json({ + approved_by: + overview.providerState.provider === 'gitlab' && + overview.providerState.approvals.actorIds.includes('9') + ? [{ user: actor }] + : [], + }); + if (path === `${root}/reviewers`) + return Response.json([ + { user: actor, state: requestedChanges ? 'requested_changes' : 'unreviewed' }, + ]); + if (path === `${root}/discussions/thread`) return Response.json({ id: 'thread', notes }); + if (path === `${root}/notes/8/award_emoji`) + return Response.json(awards, { headers: { 'x-next-page': '' } }); + if (path === '/projects/123/repository/branches/feature') + return branchExists ? Response.json(branch) : Response.json({}, { status: 404 }); + // Response fields: https://docs.gitlab.com/api/draft_notes/#get-a-single-draft-note + if (path === `${root}/draft_notes/11` && drafts.has('11')) + return Response.json({ + id: 11, + author_id: 9, + merge_request_id: 77, + ...draftDiscussion, + note: 'Draft', + position: nativePosition, + }); + return Response.json({}, { status: 404 }); + } + const incoming = new Request(String(destination), init); + const body = !init?.body + ? {} + : incoming.headers.get('content-type')?.startsWith('multipart/form-data') + ? Object.fromEntries(await incoming.formData()) + : await incoming.json(); + beforeWrite?.(); + const failure = failures.get(`${method} ${path}`); + if (failure) return Response.json({}, { status: failure }); + let response: Response; + if (path === `${root}/notes` && method === 'POST') { + const value = { ...note, id: notes.length + 40, body: body.body, author: providerActor }; + notes.push(value); + response = Response.json(value, { status: 201 }); + } else if (path === `${root}/discussions` && method === 'POST') { + const p = body.position ?? { + position_type: body['position[position_type]'], + head_sha: body['position[head_sha]'], + base_sha: body['position[base_sha]'], + start_sha: body['position[start_sha]'], + old_path: body['position[old_path]'], + new_path: body['position[new_path]'], + old_line: body['position[old_line]'] ? Number(body['position[old_line]']) : null, + new_line: body['position[new_line]'] ? Number(body['position[new_line]']) : null, + ...(body['position[line_range][start][line_code]'] + ? { + line_range: { + start: { + line_code: body['position[line_range][start][line_code]'], + type: body['position[line_range][start][type]'], + new_line: Number(body['position[line_range][start][new_line]']), + }, + end: { + line_code: body['position[line_range][end][line_code]'], + type: body['position[line_range][end][type]'], + new_line: Number(body['position[line_range][end][new_line]']), + }, + }, + } + : {}), + }; + if ( + p.head_sha !== revision.headSha || + p.base_sha !== revision.baseSha || + p.start_sha !== revision.startSha || + p.old_path !== 'old.ts' || + p.new_path !== 'new.ts' + ) + return Response.json({}, { status: 400 }); + const value = { + ...note, + id: notes.length + 40, + body: body.body, + author: providerActor, + position: p, + }; + notes.push(value); + response = Response.json({ id: 'new-thread', notes: [value] }, { status: 201 }); + } else if (path === `${root}/discussions/thread/notes` && method === 'POST') { + const value = { ...note, id: notes.length + 40, body: body.body }; + notes.push(value); + response = Response.json(value, { status: 201 }); + } else if (path === `${root}/discussions/thread` && method === 'PUT') { + const resolved = url.searchParams.get('resolved'); + if (resolved !== 'true' && resolved !== 'false') return Response.json({}, { status: 400 }); + notes[0].resolved = resolved === 'true'; + response = Response.json({ id: 'thread', notes: [notes[0]] }); + } else if (path === `${root}/notes/8/award_emoji` && method === 'POST') { + const value = { id: 20, name: body.name, user: providerActor }; + awards.push(value); + response = Response.json(value, { status: 201 }); + } else if (path === `${root}/notes/8/award_emoji/20` && method === 'DELETE') { + awards = []; + response = new Response(null, { status: 204 }); + } else if (path === `${root}/approve` && method === 'POST') { + if (body.sha !== review.sha) return Response.json({}, { status: 409 }); + overview.providerState = { + ...overview.providerState, + provider: 'gitlab', + approvals: { approved: true, required: 0, remaining: 0, actorIds: ['9'] }, + requestedChanges: ( + overview.providerState as Extract + ).requestedChanges, + }; + response = Response.json({ approved_by: [{ user: providerActor }] }); + } else if (path === `${root}/unapprove` && method === 'POST') { + if (overview.providerState.provider === 'gitlab') + overview.providerState.approvals.actorIds = []; + response = new Response(null, { status: 204 }); + } else if (url.pathname === '/GitLab/api/graphql' && method === 'POST') { + response = Response.json( + await graphql({ + schema: graph, + source: body.query, + variableValues: body.variables, + rootValue: { + mergeRequestRequestChanges: ({ + input, + }: { + input: { projectPath: string; iid: string }; + }) => { + if (input.projectPath !== repository.fullName || input.iid !== '7') + return { mergeRequest: null, errors: ['Wrong target'] }; + requestedChanges = true; + return { mergeRequest: { id: 'gid://gitlab/MergeRequest/77', iid: '7' }, errors: [] }; + }, + }, + }) + ); + } else if (path === `${root}/merge` && method === 'PUT') { + if (body.sha !== review.sha) return Response.json({}, { status: 409 }); + if ( + (project.squash_option === 'always' && body.squash !== true) || + (project.squash_option === 'never' && body.squash !== false) || + body.should_remove_source_branch !== false + ) + return Response.json({}, { status: 400 }); + review.squash = body.squash; + if (body.auto_merge || body.merge_when_pipeline_succeeds) { + if (version === '17.10.0' ? body.auto_merge : body.merge_when_pipeline_succeeds) + return Response.json({}, { status: 400 }); + review.merge_when_pipeline_succeeds = true; + } else if (acceptedResponse !== path) review.state = 'merged'; + response = Response.json(review); + } else if (path === `${root}/cancel_merge_when_pipeline_succeeds` && method === 'POST') { + review.merge_when_pipeline_succeeds = false; + response = Response.json(review); + } else if (path === `${root}/rebase` && method === 'PUT') { + review.rebase_in_progress = true; + response = Response.json({ rebase_in_progress: true }, { status: 202 }); + } else if (path === '/projects/123/repository/branches/feature' && method === 'DELETE') { + branchExists = false; + response = new Response(null, { status: 204 }); + } else if (path === `${root}/draft_notes/11/publish` && method === 'PUT') { + drafts.delete('11'); + notes.push({ ...note, id: 11, body: 'Draft' }); + // https://raw.githubusercontent.com/gitlabhq/gitlabhq/v17.11.0/app/services/draft_notes/publish_service.rb + if (draftDiscussion.discussion_id) notes[0].resolved = draftDiscussion.resolve_discussion; + response = new Response(null, { status: 204 }); + } else return Response.json({}, { status: 404 }); + writes.push({ path, body, method }); + if (loseResponse === path) throw new Error('Connection lost with fixture-token'); + if (acceptedResponse === path) { + const text = await response.text(); + return Response.json(text ? JSON.parse(text) : {}, { status: 202 }); + } + return response; + }); +}); + +it.each(['user', 'org'] as const)( + 'AC6 writes as the authorized GitLab actor for %s ownership', + async owner => { + const input = request({ action: 'comment', body: 'Comment' }); + if (owner === 'org') { + auth.authorization = { + ...authorization, + owner: { type: 'org', id: '33333333-3333-4333-8333-333333333333' }, + }; + input.intent.review = { ...identity, authorization: auth.authorization }; + overview.identity = input.intent.review; + } + expect(await runGitLabReviewOperation(auth, input)).toMatchObject({ + status: 'confirmed', + reference: { provider: 'gitlab', kind: 'comment' }, + }); + expect(notes.at(-1)).toMatchObject({ body: 'Comment', author: actor, noteable_id: 77 }); + expect(writes).toHaveLength(1); + } +); +it('AC6 preserves inline old/new paths, immutable refs, side, and range through the real SDK', async () => { + expect(await run({ action: 'inlineComment', body: 'Inline', position })).toMatchObject({ + status: 'confirmed', + }); + expect(notes.at(-1)).toMatchObject({ body: 'Inline', position: nativePosition }); +}); +it('AC6 rejects stale SHA before dispatch and preserves the original position', async () => { + review.sha = 'd'.repeat(40); + review.diff_refs.head_sha = review.sha; + expect(await run({ action: 'inlineComment', body: 'Stale', position })).toMatchObject({ + status: 'rejected', + code: 'conflict', + retry: 'never', + }); + expect(writes).toEqual([]); + expect(position.revision).toEqual(revision); +}); +it('AC6 rejects a mismatched actor without changing the provider', async () => { + const input = request({ action: 'comment', body: 'Wrong actor' }); + input.intent.actorId = '10'; + expect(await runGitLabReviewOperation(auth, input)).toMatchObject({ + status: 'rejected', + code: 'operation_identity_mismatch', + }); + expect(notes).toHaveLength(1); + expect(writes).toEqual([]); +}); +it('AC6 keeps lost comment responses unresolved without body-based replay', async () => { + loseResponse = `${root}/notes`; + const input = { action: 'comment' as const, body: 'Once' }; + expect(await run(input)).toMatchObject({ status: 'unresolved', retry: 'reconcile' }); + expect(await run(input)).toMatchObject({ status: 'unresolved', retry: 'reconcile' }); + expect(notes.filter(value => value.body === 'Once')).toHaveLength(1); + expect(writes).toHaveLength(1); +}); +it('AC6 reconciles a saved comment after a failed postflight read without replay', async () => { + failReadAfterWrite = true; + const input: ReviewIntentInput = { action: 'comment', body: 'Saved' }; + expect(await run(input)).toMatchObject({ + status: 'unresolved', + reference: { id: '41' }, + retry: 'reconcile', + }); + expect(notes.at(-1)?.body).toBe('Saved'); + failReadAfterWrite = false; + expect(await run(input)).toMatchObject({ status: 'confirmed', reference: { id: '41' } }); + expect(writes).toHaveLength(1); +}); +it.each(['comment', 'approve', 'merge', 'requestChanges'] as const)( + 'AC6/AC7 blocks %s with denied write grants', + async action => { + auth.scopes = ['read_api']; + expect( + await run( + action === 'comment' + ? { action, body: 'Denied' } + : action === 'merge' + ? { action, method: 'ff' } + : { action } + ) + ).toMatchObject({ status: 'rejected', code: 'forbidden' }); + expect(writes).toEqual([]); + } +); +it.each(['reply', 'resolveThread', 'reopenThread'] as const)( + 'AC6 supports %s on the exact discussion', + async action => { + notes[0].resolved = action === 'reopenThread'; + expect( + await run({ + action, + target: { + provider: 'gitlab', + kind: 'thread', + id: 'thread', + url: `${identity.canonicalUrl}#note_8`, + }, + ...(action === 'reply' ? { body: 'Reply' } : {}), + }) + ).toMatchObject({ status: 'confirmed' }); + if (action === 'reply') expect(notes.at(-1)?.body).toBe('Reply'); + else expect(notes[0].resolved).toBe(action === 'resolveThread'); + } +); +it.each(['addReaction', 'removeReaction'] as const)( + 'AC6 supports %s for the actual provider actor', + async action => { + if (action === 'removeReaction') awards.push({ id: 20, name: 'thumbsup', user: actor }); + expect( + await run({ + action, + target: { provider: 'gitlab', kind: 'comment', id: '8', url: null }, + reaction: action === 'addReaction' ? 'thumbsup' : '20', + }) + ).toMatchObject({ status: 'confirmed' }); + expect(awards).toEqual( + action === 'addReaction' ? [{ id: 20, name: 'thumbsup', user: actor }] : [] + ); + } +); +it('AC6 refuses removal of another actor reaction', async () => { + awards.push({ id: 20, name: 'thumbsup', user: { ...actor, id: 10 } }); + expect( + await run({ + action: 'removeReaction', + target: { provider: 'gitlab', kind: 'comment', id: '8', url: null }, + reaction: '20', + }) + ).toMatchObject({ status: 'rejected', code: 'forbidden' }); + expect(awards).toHaveLength(1); + expect(writes).toEqual([]); +}); +it.each(['approve', 'unapprove'] as const)('AC6 supports %s without GitHub calls', async action => { + expect(await run({ action })).toMatchObject({ status: 'confirmed' }); + expect(overview.providerState).toMatchObject({ + approvals: { actorIds: action === 'approve' ? ['9'] : [] }, + }); +}); +it('AC6 enforces the provider approval SHA guard after a preflight race', async () => { + beforeWrite = () => { + review.sha = 'd'.repeat(40); + review.diff_refs.head_sha = review.sha; + }; + expect(await run({ action: 'approve' })).toMatchObject({ status: 'rejected', code: 'conflict' }); + expect(overview.providerState).toMatchObject({ approvals: { actorIds: [] } }); +}); +it('AC6 requests changes through authorized GraphQL without licensed merge blocking', async () => { + overview.authorization.capabilities.requestChanges.version = 'unknown'; + expect(await run({ action: 'requestChanges' })).toMatchObject({ status: 'confirmed' }); + expect(requestedChanges).toBe(true); + expect(overview.providerState).toMatchObject({ requestedChanges: { blocksMerge: false } }); +}); +it('AC6 keeps publication, summary, and approval separate after partial failure', async () => { + const input: ReviewIntentInput = { + action: 'submitReview', + comments: [ + { itemId: '11', body: 'Draft', position }, + { itemId: 'second', body: 'Second', position }, + ], + draftReferences: [ + { provider: 'gitlab', kind: 'comment', id: '11', url: identity.canonicalUrl }, + ], + body: 'Summary', + choice: 'approve', + }; + failures.set(`POST ${root}/discussions`, 429); + const partial = await run(input); + expect(partial).toMatchObject({ + status: 'partial', + items: [ + { result: { status: 'confirmed' } }, + { result: { status: 'rejected', retry: 'same-key' } }, + { result: { code: 'previous_effect_unconfirmed' } }, + { result: { code: 'previous_effect_unconfirmed' } }, + ], + }); + expect(drafts.size).toBe(0); + expect(notes.map(value => value.body)).toEqual(['Original', 'Draft']); + failures.clear(); + expect(await run(input)).toMatchObject({ status: 'confirmed' }); + expect(notes.map(value => value.body)).toEqual(['Original', 'Draft', 'Second', 'Summary']); + expect(writes.filter(value => value.path.endsWith('/publish'))).toHaveLength(1); + expect(overview.providerState).toMatchObject({ approvals: { actorIds: ['9'] } }); + expect(input.comments?.[1].position).toEqual(position); +}); +it('AC6 keeps an empty review empty without dispatching content or approval', async () => { + expect(await run({ action: 'submitReview', comments: [], choice: 'comment' })).toMatchObject({ + status: 'confirmed', + reference: null, + }); + expect(writes).toEqual([]); + expect(notes).toHaveLength(1); +}); +it.each(['17.10.0', '17.11.0', '18.0.0'])( + 'AC7/AC10 uses the authorized %s auto-merge form', + async instanceVersion => { + version = instanceVersion; + expect(await run({ action: 'enableAutoMerge', method: 'ff', squash: true })).toMatchObject({ + status: 'confirmed', + }); + expect(review.merge_when_pipeline_succeeds).toBe(true); + expect(review.state).toBe('opened'); + expect(branchExists).toBe(true); + } +); +it('AC7 cancels auto-merge and reconciles accepted rebase progress', async () => { + review.merge_when_pipeline_succeeds = true; + expect(await run({ action: 'disableAutoMerge' })).toMatchObject({ status: 'confirmed' }); + expect(review.merge_when_pipeline_succeeds).toBe(false); + const rebase = request({ action: 'updateBranch' }); + rebase.operationKey = '44444444-4444-4444-8444-444444444444'; + expect(await runGitLabReviewOperation(auth, rebase)).toMatchObject({ + status: 'accepted', + retry: 'reconcile', + }); + expect(await runGitLabReviewOperation(auth, rebase, true)).toMatchObject({ status: 'accepted' }); + review.rebase_in_progress = false; + review.sha = 'd'.repeat(40); + review.diff_refs.head_sha = review.sha; + expect(await runGitLabReviewOperation(auth, rebase, true)).toMatchObject({ status: 'confirmed' }); + expect(writes.filter(value => value.path.endsWith('/rebase'))).toHaveLength(1); +}); +it.each(['method', 'squash', 'restriction', 'empty'] as const)( + 'AC7 rejects invalid %s before merge', + async kind => { + const input: ReviewIntentInput = { action: 'merge', method: 'ff', squash: true }; + if (kind === 'method') input.method = 'rebase'; + if (kind === 'squash') input.squash = false; + if (kind === 'restriction') + overview.authorization.capabilities.merge.restrictions = ['pipeline_not_successful']; + if (kind === 'empty') overview.merge.methods = []; + expect(await run(input)).toMatchObject({ status: 'rejected', code: 'conflict' }); + expect(review.state).toBe('opened'); + expect(writes).toEqual([]); + } +); +it('AC7 reconciles a lost merge response by exact source SHA without merging twice', async () => { + loseResponse = `${root}/merge`; + const input: ReviewIntentInput = { action: 'merge', method: 'ff', squash: true }; + expect(await run(input)).toMatchObject({ status: 'unresolved' }); + expect(review.state).toBe('merged'); + expect(await run(input)).toMatchObject({ status: 'confirmed' }); + expect(writes).toHaveLength(1); +}); +it('AC7 preserves confirmed merge separately from denied source deletion', async () => { + failures.set('DELETE /projects/123/repository/branches/feature', 403); + const input: ReviewIntentInput = { + action: 'merge', + method: 'ff', + deletion: { + effect: 'delete', + repositoryKey: repositoryResourceKey(userId, identity), + branch: 'feature', + expectedHeadSha: revision.headSha, + }, + }; + expect(await run(input)).toMatchObject({ + status: 'partial', + items: [ + { result: { status: 'confirmed' } }, + { result: { status: 'rejected', code: 'forbidden' } }, + ], + }); + expect(review.state).toBe('merged'); + expect(branchExists).toBe(true); + await run(input); + expect(writes.filter(value => value.path.endsWith('/merge'))).toHaveLength(1); +}); +it('AC7 deletes only the server-derived source after confirmed merge', async () => { + expect( + await run({ + action: 'merge', + method: 'ff', + deletion: { + effect: 'delete', + repositoryKey: repositoryResourceKey(userId, identity), + branch: 'feature', + expectedHeadSha: revision.headSha, + }, + }) + ).toMatchObject({ status: 'confirmed' }); + expect(review.state).toBe('merged'); + expect(branchExists).toBe(false); + expect(writes.map(value => value.method)).toEqual(['PUT', 'DELETE']); +}); +it('AC7 refuses a forged deletion choice before any merge effect', async () => { + expect( + await run({ + action: 'merge', + method: 'ff', + deletion: { + effect: 'delete', + repositoryKey: repositoryResourceKey(userId, identity), + branch: 'someone-elses-branch', + expectedHeadSha: revision.headSha, + }, + }) + ).toMatchObject({ + status: 'partial', + items: [ + { result: { status: 'rejected', code: 'forbidden' } }, + { result: { status: 'rejected', code: 'previous_effect_unconfirmed' } }, + ], + }); + expect(review.state).toBe('opened'); + expect(branchExists).toBe(true); + expect(writes).toEqual([]); +}); +it.each([ + 'reply', + 'resolveThread', + 'reopenThread', + 'addReaction', + 'removeReaction', + 'unapprove', + 'requestChanges', +] as const)('AC6 reconciles %s from its receipt after a read failure', async action => { + const input: ReviewIntentInput = + action === 'reply' || action === 'resolveThread' || action === 'reopenThread' + ? { + action, + target: { provider: 'gitlab', kind: 'thread', id: 'thread', url: null }, + ...(action === 'reply' ? { body: 'Reply' } : {}), + } + : action === 'addReaction' || action === 'removeReaction' + ? { + action, + target: { provider: 'gitlab', kind: 'comment', id: '8', url: null }, + reaction: action === 'addReaction' ? 'thumbsup' : '20', + } + : { action }; + notes[0].resolved = action === 'reopenThread'; + if (action === 'removeReaction') awards.push({ id: 20, name: 'thumbsup', user: actor }); + failReadAfterWrite = true; + expect(await run(input)).toMatchObject({ status: 'unresolved', retry: 'reconcile' }); + failReadAfterWrite = false; + expect(await run(input)).toMatchObject({ status: 'confirmed' }); + expect(writes).toHaveLength(1); +}); +it('AC6 keeps an accepted reaction unresolved when its award is missing without replay', async () => { + acceptedResponse = `${root}/notes/8/award_emoji`; + const input: ReviewIntentInput = { + action: 'addReaction', + target: { provider: 'gitlab', kind: 'comment', id: '8', url: null }, + reaction: 'thumbsup', + }; + expect(await run(input)).toMatchObject({ status: 'accepted', reference: { id: '20' } }); + awards = []; + expect(await run(input)).toMatchObject({ + status: 'unresolved', + reason: 'provider_outcome_unknown', + reference: { id: '20' }, + retry: 'reconcile', + }); + expect(awards).toEqual([]); + expect(writes).toHaveLength(1); +}); +it.each(['side', 'line', 'range', 'provider'] as const)( + 'AC6 rejects inconsistent inline %s without dispatch', + async change => { + const selected = structuredClone(position); + if (change === 'side') selected.side = 'old'; + if (change === 'line') selected.line = 9; + if (change === 'range') selected.startLine = 4; + if (change === 'provider') selected.native = { provider: 'github' }; + await expect( + run({ action: 'inlineComment', body: 'Keep this draft', position: selected }) + ).rejects.toMatchObject({ code: 'invalid_request' }); + expect(writes).toEqual([]); + expect(notes).toHaveLength(1); + } +); +it.each([ + { action: 'approve', body: 'Do not silently discard this summary' }, + { action: 'comment', body: 'Do not silently drop this position', position }, + { action: 'addReaction', reaction: 'thumbsup' }, +] satisfies ReviewIntentInput[])( + 'AC6 rejects incomplete or ignored input for $action', + async input => { + await expect(run(input)).rejects.toMatchObject({ code: 'invalid_request' }); + expect(writes).toEqual([]); + } +); +it('AC6 does not confirm a note from a different provider actor', async () => { + providerActor = { ...actor, id: 10 }; + const input: ReviewIntentInput = { action: 'comment', body: 'Actor mismatch' }; + expect(await run(input)).toMatchObject({ status: 'unresolved' }); + expect(await run(input)).toMatchObject({ status: 'unresolved' }); + expect(notes.at(-1)?.author.id).toBe(10); + expect(writes).toHaveLength(1); +}); +it('AC6 preserves unfinished original positions when the head advances after partial publication', async () => { + const input: ReviewIntentInput = { + action: 'submitReview', + comments: [ + { itemId: 'first', body: 'First', position }, + { itemId: 'second', body: 'Second', position }, + ], + body: 'Summary', + choice: 'approve', + }; + let attempts = 0; + beforeWrite = () => { + if (++attempts === 2) failures.set(`POST ${root}/discussions`, 429); + }; + expect(await run(input)).toMatchObject({ status: 'partial' }); + beforeWrite = undefined; + failures.clear(); + review.sha = 'd'.repeat(40); + review.diff_refs.head_sha = review.sha; + expect(await run(input)).toMatchObject({ + status: 'partial', + items: [ + { result: { status: 'confirmed' } }, + { result: { status: 'rejected', code: 'conflict' } }, + { result: { code: 'previous_effect_unconfirmed' } }, + { result: { code: 'previous_effect_unconfirmed' } }, + ], + }); + expect(notes.map(item => item.body)).toEqual(['Original', 'First']); + expect(writes).toHaveLength(1); + expect(input.comments?.[1].position).toEqual(position); +}); +it('AC7 rejects a changed merge SHA at the provider boundary', async () => { + beforeWrite = () => { + review.sha = 'd'.repeat(40); + review.diff_refs.head_sha = review.sha; + }; + expect(await run({ action: 'merge', method: 'ff' })).toMatchObject({ + status: 'rejected', + code: 'conflict', + }); + expect(review.state).toBe('opened'); + expect(branchExists).toBe(true); + expect(writes).toEqual([]); +}); +it.each(['default', 'protected', 'can_push'] as const)( + 'AC7 respects source branch %s restrictions before merge', + async restriction => { + branch[restriction] = restriction !== 'can_push'; + expect( + await run({ + action: 'merge', + method: 'ff', + deletion: { + effect: 'delete', + repositoryKey: repositoryResourceKey(userId, identity), + branch: 'feature', + expectedHeadSha: revision.headSha, + }, + }) + ).toMatchObject({ status: 'partial' }); + expect(review.state).toBe('opened'); + expect(branchExists).toBe(true); + expect(writes).toEqual([]); + } +); +it('AC7 confirms a lost deletion response without replaying merge or deletion', async () => { + loseResponse = '/projects/123/repository/branches/feature'; + const input: ReviewIntentInput = { + action: 'merge', + method: 'ff', + deletion: { + effect: 'delete', + repositoryKey: repositoryResourceKey(userId, identity), + branch: 'feature', + expectedHeadSha: revision.headSha, + }, + }; + expect(await run(input)).toMatchObject({ status: 'partial' }); + expect(branchExists).toBe(false); + expect(await run(input)).toMatchObject({ status: 'confirmed' }); + expect(writes.map(item => item.method)).toEqual(['PUT', 'DELETE']); +}); +it('AC7 keeps an unrecorded rebase response unresolved instead of repeating the rebase', async () => { + loseResponse = `${root}/rebase`; + expect(await run({ action: 'updateBranch' })).toMatchObject({ status: 'unresolved' }); + expect(await run({ action: 'updateBranch' })).toMatchObject({ status: 'unresolved' }); + expect(review.rebase_in_progress).toBe(true); + expect(writes).toHaveLength(1); +}); +it('AC7 reports a provider-rejected rebase task without another write', async () => { + expect(await run({ action: 'updateBranch' })).toMatchObject({ status: 'accepted' }); + review.rebase_in_progress = false; + review.merge_error = 'Cannot rebase'; + expect( + await runGitLabReviewOperation(auth, request({ action: 'updateBranch' }), true) + ).toMatchObject({ status: 'rejected', code: 'rebase_failed', retry: 'never' }); + expect(writes).toHaveLength(1); +}); +it.each(['old', 'new'] as const)( + 'AC6 preserves the %s side for single-line inline notes', + async side => { + const selected: ReviewPosition = { + revision, + oldPath: 'old.ts', + newPath: 'new.ts', + side, + line: 3, + native: { + provider: 'gitlab', + oldLine: side === 'old' ? 3 : null, + newLine: side === 'new' ? 3 : null, + }, + }; + expect( + await run({ action: 'inlineComment', body: 'Single line', position: selected }) + ).toMatchObject({ status: 'confirmed' }); + expect(notes.at(-1)).toMatchObject({ + position: { old_line: side === 'old' ? 3 : null, new_line: side === 'new' ? 3 : null }, + }); + } +); +it.each(['reply', 'resolveThread'] as const)( + 'AC6 keeps %s usable for an image discussion with a deleted author', + async action => { + Object.assign(notes[0], { author: null, position: { position_type: 'image' } }); + expect( + await run({ + action, + target: { provider: 'gitlab', kind: 'thread', id: 'thread', url: null }, + ...(action === 'reply' ? { body: 'Image reply' } : {}), + }) + ).toMatchObject({ status: 'confirmed' }); + if (action === 'reply') expect(notes.at(-1)?.body).toBe('Image reply'); + else expect(notes[0].resolved).toBe(true); + } +); +it('AC6 refuses an inline selection absent from the current diff', async () => { + jest.mocked(listGitLabFiles).mockResolvedValue({ items: [], nextCursor: null }); + expect(await run({ action: 'inlineComment', body: 'Keep this text', position })).toMatchObject({ + status: 'rejected', + code: 'conflict', + }); + expect(notes).toHaveLength(1); + expect(writes).toEqual([]); +}); +it.each([ + ['merge', 'default_off', false], + ['rebase_merge', 'default_on', true], + ['ff', 'always', true], + ['ff', 'never', false], +] as const)('AC7 follows the %s method and %s squash policy', async (method, squash, expected) => { + project.merge_method = method; + project.squash_option = squash; + overview.merge.methods = [{ id: method, label: method }]; + expect(await run({ action: 'merge', method })).toMatchObject({ status: 'confirmed' }); + expect(review.state).toBe('merged'); + expect(review.squash).toBe(expected); +}); +it('AC7 applies the selected squash choice instead of the optional default', async () => { + project.squash_option = 'default_off'; + expect(await run({ action: 'merge', method: 'ff', squash: true })).toMatchObject({ + status: 'confirmed', + }); + expect(review.squash).toBe(true); +}); +it('AC7 rejects changed live project policy before merge', async () => { + project.merge_method = 'merge'; + expect(await run({ action: 'merge', method: 'ff' })).toMatchObject({ + status: 'rejected', + code: 'conflict', + }); + expect(review.state).toBe('opened'); + expect(writes).toEqual([]); +}); +it('AC10 never guesses an auto-merge form when the authorized version is unknown', async () => { + version = 'unknown'; + expect(await run({ action: 'enableAutoMerge', method: 'ff' })).toMatchObject({ + status: 'rejected', + }); + expect(review.merge_when_pipeline_succeeds).toBe(false); + expect(writes).toEqual([]); +}); +it.each([ + [`${root}/notes`, { action: 'comment', body: 'Queued comment' }], + [`${root}/discussions`, { action: 'inlineComment', body: 'Queued inline', position }], + [ + `${root}/discussions/thread/notes`, + { + action: 'reply', + body: 'Queued reply', + target: { provider: 'gitlab', kind: 'thread', id: 'thread', url: null }, + }, + ], + [ + `${root}/discussions/thread`, + { + action: 'resolveThread', + target: { provider: 'gitlab', kind: 'thread', id: 'thread', url: null }, + }, + ], + [ + `${root}/notes/8/award_emoji`, + { + action: 'addReaction', + target: { provider: 'gitlab', kind: 'comment', id: '8', url: null }, + reaction: 'thumbsup', + }, + ], + [ + `${root}/notes/8/award_emoji/20`, + { + action: 'removeReaction', + target: { provider: 'gitlab', kind: 'comment', id: '8', url: null }, + reaction: '20', + }, + ], + [`${root}/approve`, { action: 'approve' }], + [`${root}/unapprove`, { action: 'unapprove' }], + ['/GitLab/api/graphql', { action: 'requestChanges' }], +] satisfies [string, ReviewIntentInput][])( + 'AC6 reconciles an accepted response at %s without reporting early success', + async (path, input) => { + acceptedResponse = path; + if (input.action === 'removeReaction') awards.push({ id: 20, name: 'thumbsup', user: actor }); + expect(await run(input)).toMatchObject({ status: 'accepted', retry: 'reconcile' }); + expect(await runGitLabReviewOperation(auth, request(input), true)).toMatchObject({ + status: 'confirmed', + }); + expect(writes).toHaveLength(1); + } +); +it('AC6 never treats an accepted draft publication as confirmed or retries its disappearance', async () => { + acceptedResponse = `${root}/draft_notes/11/publish`; + const input: ReviewIntentInput = { + action: 'submitReview', + comments: [{ itemId: '11', body: 'Draft', position }], + draftReferences: [{ provider: 'gitlab', kind: 'comment', id: '11', url: null }], + }; + expect(await run(input)).toMatchObject({ + status: 'partial', + items: [{ result: { status: 'accepted' } }], + }); + expect(await runGitLabReviewOperation(auth, request(input), true)).toMatchObject({ + status: 'partial', + items: [{ result: { status: 'unresolved' } }], + }); + expect(drafts.size).toBe(0); + expect(writes).toHaveLength(1); +}); +it('AC7 polls an accepted merge before reporting provider-confirmed completion', async () => { + acceptedResponse = `${root}/merge`; + const input: ReviewIntentInput = { action: 'merge', method: 'ff' }; + expect(await run(input)).toMatchObject({ status: 'accepted' }); + expect(review.state).toBe('opened'); + review.state = 'merged'; + expect(await runGitLabReviewOperation(auth, request(input), true)).toMatchObject({ + status: 'confirmed', + }); + expect(writes).toHaveLength(1); +}); +it('AC7 confirms accepted source deletion through branch absence without another delete', async () => { + acceptedResponse = '/projects/123/repository/branches/feature'; + review.state = 'merged'; + const input: ReviewIntentInput = { + action: 'deleteBranch', + deletion: { + effect: 'delete', + repositoryKey: repositoryResourceKey(userId, identity), + branch: 'feature', + expectedHeadSha: revision.headSha, + }, + }; + expect(await run(input)).toMatchObject({ status: 'accepted' }); + expect(await runGitLabReviewOperation(auth, request(input), true)).toMatchObject({ + status: 'confirmed', + }); + expect(branchExists).toBe(false); + expect(writes).toHaveLength(1); +}); +it('AC10 preserves canonical host normalization in authorized provider URLs', async () => { + review.web_url = identity.canonicalUrl.replace('gitlab.com', 'GITLAB.COM'); + expect(await run({ action: 'comment', body: 'Canonical host' })).toMatchObject({ + status: 'confirmed', + }); + expect(notes.at(-1)?.body).toBe('Canonical host'); +}); +it('AC6 validates every batch key before publishing its first item', async () => { + await expect( + run({ + action: 'submitReview', + comments: [ + { itemId: 'first', body: 'First', position }, + { itemId: 'x'.repeat(512), body: 'Second', position }, + ], + }) + ).rejects.toMatchObject({ code: 'invalid_request' }); + expect(notes).toHaveLength(1); + expect(writes).toEqual([]); +}); +it.each([true, false])( + 'AC7 uses current project-actor merge permission %s when token scopes are unknown', + async allowed => { + auth.scopes = null; + auth.credentialKind = 'gitlabProjectToken'; + auth.projectTokenId = '123'; + overview.authorization.capabilities.merge.permission = 'unknown'; + review.user.can_merge = allowed; + expect(await run({ action: 'merge', method: 'ff' })).toMatchObject({ + status: allowed ? 'confirmed' : 'rejected', + }); + expect(review.state).toBe(allowed ? 'merged' : 'opened'); + expect(writes).toHaveLength(allowed ? 1 : 0); + } +); +it.each(['forbidden', 'not_connected', 'reconnect_required', 'request_too_large'] as const)( + 'AC6 treats pre-dispatch %s after preflight as a rejection, not an unknown write', + async code => { + let lookups = 0; + jest.mocked(fetchGitLabCredential).mockImplementation(async () => { + if (++lookups === 5) throw new GitLabInteractiveError(code); + return { status: 'available', token: 'fixture-token', instanceUrl, glabIsOAuth2: false }; + }); + expect(await run({ action: 'comment', body: 'Preserve this work' })).toMatchObject({ + status: 'rejected', + code, + retry: 'never', + }); + expect(notes).toHaveLength(1); + expect(writes).toEqual([]); + } +); +it.each(['confirmed', 'rejected'] as const)( + 'AC7 recovers an accepted rebase after unavailable status reads with outcome %s', + async outcome => { + const input: ReviewIntentInput = { action: 'updateBranch' }; + expect(await run(input)).toMatchObject({ status: 'accepted', retry: 'reconcile' }); + failReadAfterWrite = true; + expect(await runGitLabReviewOperation(auth, request(input), true)).toMatchObject({ + status: 'unresolved', + reason: 'reconciliation_unavailable', + retry: 'reconcile', + }); + records = new Map(JSON.parse(JSON.stringify([...records]))); + failReadAfterWrite = false; + review.rebase_in_progress = false; + review.merge_error = outcome === 'rejected' ? 'Cannot rebase' : null; + review.sha = 'd'.repeat(40); + review.diff_refs.head_sha = review.sha; + const result = await run(input); + expect(result).toMatchObject({ status: outcome, retry: 'never' }); + if (outcome === 'rejected') expect(result).toMatchObject({ code: 'rebase_failed' }); + expect(await runGitLabReviewOperation(auth, request(input), true)).toEqual(result); + expect(writes).toHaveLength(1); + } +); +it.each([ + { source: 'admitted', headSha: revision.headSha, status: 'confirmed' }, + { source: 'different', headSha: 'd'.repeat(40), status: 'unresolved' }, +] as const)( + 'AC7 reconciles lost auto-merge completion for the $source source SHA', + async ({ headSha, status }) => { + loseResponse = `${root}/merge`; + const input: ReviewIntentInput = { action: 'enableAutoMerge', method: 'ff', squash: true }; + expect(await run(input)).toMatchObject({ status: 'unresolved', retry: 'reconcile' }); + Object.assign(review, { + state: 'merged', + sha: headSha, + merge_when_pipeline_succeeds: false, + auto_merge_enabled: false, + diff_refs: { ...review.diff_refs, head_sha: headSha, base_sha: 'e'.repeat(40) }, + }); + expect(await run(input)).toMatchObject({ status }); + expect(await runGitLabReviewOperation(auth, request(input), true)).toMatchObject({ status }); + expect(review.state).toBe('merged'); + expect(writes).toHaveLength(1); + } +); +it.each([ + ['e'.repeat(40), false], + ['e'.repeat(40), true], + [null, true], +] as const)( + 'AC6 rejects unbound draft discussion %s with resolve_discussion=%s', + async (discussion_id, resolve_discussion) => { + draftDiscussion = { discussion_id, resolve_discussion }; + notes[0].resolved = !resolve_discussion; + const originalNotes = structuredClone(notes); + const input: ReviewIntentInput = { + action: 'submitReview', + comments: [{ itemId: '11', body: 'Draft', position }], + draftReferences: [{ provider: 'gitlab', kind: 'comment', id: '11', url: null }], + body: 'Summary', + choice: 'approve', + }; + const result = await run(input); + expect(result).toMatchObject({ + status: 'partial', + items: [ + { + itemId: 'comment:11', + effect: 'inlineComment', + result: { status: 'rejected', code: 'conflict', retry: 'never' }, + }, + { result: { code: 'previous_effect_unconfirmed' } }, + { result: { code: 'previous_effect_unconfirmed' } }, + ], + }); + expect(await run(input)).toEqual(result); + expect(notes).toEqual(originalNotes); + expect(drafts.has('11')).toBe(true); + expect(overview.providerState).toMatchObject({ approvals: { actorIds: [] } }); + expect(writes).toEqual([]); + } +); diff --git a/apps/web/src/lib/provider-review/gitlab-write.ts b/apps/web/src/lib/provider-review/gitlab-write.ts new file mode 100644 index 0000000000..4c5954e09b --- /dev/null +++ b/apps/web/src/lib/provider-review/gitlab-write.ts @@ -0,0 +1,1123 @@ +import 'server-only'; + +import { z } from 'zod'; +import { repositoryResourceKey } from '@kilocode/app-shared/code-review/repository-identity'; +import { + ReviewIntentInputSchema, + ReviewPositionSchema, + ReviewRevisionSchema, + reviewResourceKey, + serializeReviewWriteRequest, + type ProviderReference, + type ReviewIntentInput, + type ReviewMutationResult, + type ReviewPosition, + type ReviewRevision, +} from '@kilocode/app-shared/provider-review'; +import { GitLabInteractiveError } from '@/lib/integrations/platforms/gitlab/interactive-client'; +import { + GitLabPathSchema, + GitLabUserSchema, + parseGitLab, + resolveGitLabReviewProject, + type GitLabReviewAuthorization, +} from './gitlab-authorization'; +import { getGitLabReview, listGitLabFiles } from './gitlab-read'; +import { + confirmedReviewEffect, + rejectedReviewEffect, + unresolvedReviewEffect, + runReviewOperation, + type ReviewEffectResult, + type ReviewOperationRequest, +} from './operation'; + +const id = z.number().int().positive().safe(); +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 positionSchema = refs.extend({ + position_type: z.literal('text'), + old_path: z.string(), + new_path: z.string(), + old_line: id.nullish(), + new_line: id.nullish(), + line_range: z + .object({ + start: z.object({ + line_code: z.string(), + type: z.enum(['old', 'new']), + old_line: id.nullish(), + new_line: id.nullish(), + }), + end: z.object({ + line_code: z.string(), + type: z.enum(['old', 'new']), + old_line: id.nullish(), + new_line: id.nullish(), + }), + }) + .nullish(), +}); +const noteSchema = z.object({ + id, + body: z.string(), + author: GitLabUserSchema.nullish(), + noteable_id: id, + // Existing image/file discussions remain replyable. Validate positions only for inline receipts. + position: z.unknown().optional(), + resolvable: z.boolean().optional(), + resolved: z.boolean().optional(), + current_user: z.object({ can_resolve: z.boolean() }).optional(), +}); +const threadSchema = z.object({ id: z.string().min(1), notes: z.array(noteSchema).min(1) }); +const awardSchema = z.object({ id, name: z.string(), user: GitLabUserSchema }); +const branchSchema = z.object({ + name: z.string(), + default: z.boolean(), + protected: z.boolean(), + can_push: z.boolean(), + commit: z.object({ id: sha }), +}); +const reviewSchema = z.object({ + id, + iid: id, + project_id: id, + target_project_id: id, + source_project_id: id.nullable(), + web_url: z.url(), + state: z.string(), + sha, + diff_refs: refs.nullish(), + source_branch: z.string().nullable(), + target_branch: z.string(), + user: z.object({ can_merge: z.boolean().optional() }).nullish(), + merge_when_pipeline_succeeds: z.boolean().optional(), + auto_merge_enabled: z.boolean().optional(), + rebase_in_progress: z.boolean().optional(), + merge_error: z.string().nullable().optional(), +}); +type Context = { auth: GitLabReviewAuthorization; request: ReviewOperationRequest }; +type Effect = { itemId: string; input: ReviewIntentInput; draft?: ProviderReference }; + +function sameRevision(expected: ReviewRevision, actual: ReviewRevision): void { + if ( + JSON.stringify(ReviewRevisionSchema.parse(expected)) !== + JSON.stringify(ReviewRevisionSchema.parse(actual)) + ) + throw new GitLabInteractiveError('conflict'); +} +function numeric(value: string): number { + return parseGitLab( + id, + Number(parseGitLab(z.string().regex(/^[1-9]\d*$/), value, 'invalid_request')), + 'invalid_request' + ); +} +function reference( + context: Context, + kind: ProviderReference['kind'], + value: string +): ProviderReference { + return { provider: 'gitlab', kind, id: value, url: context.request.intent.review.canonicalUrl }; +} +function pending(reference: ProviderReference): ReviewEffectResult { + return { + status: 'accepted', + reference, + task: null, + retry: 'reconcile', + reconciliation: 'pending', + }; +} +function target( + context: Context, + input: ReviewIntentInput, + kind: ProviderReference['kind'] +): string { + const value = input.target; + if ( + !value || + value.provider !== 'gitlab' || + value.kind !== kind || + (value.url !== null && value.url.split('#')[0] !== context.request.intent.review.canonicalUrl) + ) + throw new GitLabInteractiveError('invalid_request'); + return parseGitLab(z.string().min(1).max(512), value.id, 'invalid_request'); +} +function gitLabPosition(position: ReviewPosition, revision: ReviewRevision) { + const value = parseGitLab(ReviewPositionSchema, position, 'invalid_request'); + sameRevision(revision, value.revision); + if ( + value.native.provider !== 'gitlab' || + !value.revision.baseSha || + !value.revision.startSha || + !value.oldPath || + !value.newPath + ) + throw new GitLabInteractiveError('invalid_request'); + const { native } = value; + const end = native.lineRange?.end; + if ( + (end + ? end.side !== value.side || (end.side === 'old' ? end.oldLine : end.newLine) !== value.line + : (value.side === 'old' ? native.oldLine : native.newLine) !== value.line) || + (value.startLine === undefined) !== (native.lineRange === undefined) || + ((value.side === 'old' ? native.oldLine : native.newLine) !== null && + (value.side === 'old' ? native.oldLine : native.newLine) !== value.line) || + (value.startSide === value.side && + value.startLine !== undefined && + value.startLine > value.line) || + (native.lineRange && + (native.lineRange.start.side !== value.startSide || + (value.startSide === 'old' + ? native.lineRange.start.oldLine + : native.lineRange.start.newLine) !== value.startLine)) + ) + throw new GitLabInteractiveError('invalid_request'); + const rangeEnd = (part: NonNullable['start']) => ({ + lineCode: part.lineCode, + type: part.side, + ...(part.oldLine === null ? {} : { oldLine: part.oldLine }), + ...(part.newLine === null ? {} : { newLine: part.newLine }), + }); + return { + positionType: 'text' as const, + baseSha: value.revision.baseSha, + headSha: value.revision.headSha, + startSha: value.revision.startSha, + oldPath: parseGitLab(GitLabPathSchema, value.oldPath, 'invalid_request'), + newPath: parseGitLab(GitLabPathSchema, value.newPath, 'invalid_request'), + ...(native.oldLine === null ? {} : { oldLine: String(native.oldLine) }), + ...(native.newLine === null ? {} : { newLine: String(native.newLine) }), + ...(native.lineRange + ? { + lineRange: { + start: rangeEnd(native.lineRange.start), + end: rangeEnd(native.lineRange.end), + }, + } + : {}), + }; +} +function matchesPosition(value: unknown, expected: ReviewPosition): boolean { + const parsed = positionSchema.safeParse(value); + if (!parsed.success) return false; + const actual = parsed.data; + const position = gitLabPosition(expected, expected.revision); + return ( + !!actual && + actual.head_sha === position.headSha && + actual.base_sha === position.baseSha && + actual.start_sha === position.startSha && + actual.old_path === position.oldPath && + actual.new_path === position.newPath && + (actual.old_line ?? null) === + (position.oldLine === undefined ? null : Number(position.oldLine)) && + (actual.new_line ?? null) === + (position.newLine === undefined ? null : Number(position.newLine)) && + (!position.lineRange + ? !actual.line_range + : ['start', 'end'].every(part => { + const key = part === 'start' ? 'start' : 'end'; + const received = actual.line_range?.[key], + selected = position.lineRange?.[key]; + return ( + received && + selected && + received.line_code === selected.lineCode && + received.type === selected.type && + (received.old_line ?? null) === (selected.oldLine ?? null) && + (received.new_line ?? null) === (selected.newLine ?? null) + ); + })) + ); +} +async function snapshot(context: Context) { + const { + auth, + request: { intent }, + } = context; + const project = await resolveGitLabReviewProject( + auth, + intent.review.repository.repositoryId, + intent.review.repository + ); + const iid = numeric(intent.review.number); + const review = parseGitLab( + reviewSchema, + ( + await project.client.execute(api => + api.MergeRequests.show(project.repository.repositoryId, iid, { + includeRebaseInProgress: true, + }) + ) + ).data + ); + if ( + String(review.id) !== intent.review.reviewId || + review.iid !== iid || + String(review.project_id) !== project.repository.repositoryId || + review.target_project_id !== review.project_id || + new URL(review.web_url).toString() !== `${project.canonicalUrl}/-/merge_requests/${iid}` || + new URL(review.web_url).toString() !== intent.review.canonicalUrl + ) + throw new GitLabInteractiveError('forbidden'); + if (review.diff_refs && review.sha !== review.diff_refs.head_sha) + throw new GitLabInteractiveError('temporarily_unavailable'); + return { + ...project, + iid, + review, + revision: { + headSha: review.sha, + baseSha: review.diff_refs?.base_sha ?? null, + startSha: review.diff_refs?.start_sha ?? null, + targetHeadSha: null, + }, + }; +} +async function preflight(context: Context, effect: Effect) { + const { + auth, + request: { intent }, + } = context; + const { input } = effect; + const overview = await getGitLabReview(auth, intent.review.repository, intent.review.number); + if ( + reviewResourceKey(auth.userId, overview.identity) !== + reviewResourceKey(auth.userId, intent.review) || + overview.identity.canonicalUrl !== intent.review.canonicalUrl + ) + throw new GitLabInteractiveError('forbidden'); + sameRevision(intent.revision, overview.revision); + const capability = overview.authorization.capabilities[input.action]; + if ( + (auth.scopes !== null && !auth.scopes.includes('api')) || + capability.permission === 'forbidden' + ) + throw new GitLabInteractiveError('forbidden'); + if ( + capability.support === 'unsupported' || + capability.version === 'unavailable' || + capability.license === 'unavailable' || + capability.restrictions.length + ) + throw new GitLabInteractiveError('conflict'); + const loaded = await snapshot(context); + if (input.action === 'merge' || input.action === 'enableAutoMerge') { + // Project-token scopes are not cached. Require the live actor permission; the API enforces token grants. + if ( + capability.permission !== 'allowed' && + !(auth.scopes === null && loaded.review.user?.can_merge === true) + ) + throw new GitLabInteractiveError('forbidden'); + if ( + !overview.merge.methods.some(method => method.id === input.method) || + loaded.project.merge_method !== input.method || + !loaded.project.squash_option || + (loaded.project.squash_option === 'always' && input.squash === false) || + (loaded.project.squash_option === 'never' && input.squash === true) + ) + throw new GitLabInteractiveError('conflict'); + } + if (input.position) { + gitLabPosition(input.position, intent.revision); + let cursor; + let found = false; + do { + const page = await listGitLabFiles(auth, intent.review, intent.revision, cursor); + found = page.items.some( + file => file.oldPath === input.position?.oldPath && file.newPath === input.position.newPath + ); + cursor = page.nextCursor; + } while (!found && cursor); + if (!found) throw new GitLabInteractiveError('conflict'); + } + if ( + input.action === 'resolveThread' || + input.action === 'reopenThread' || + input.action === 'reply' + ) { + const threadId = target(context, input, 'thread'); + const thread = parseGitLab( + threadSchema, + ( + await loaded.client.execute(api => + api.MergeRequestDiscussions.show(loaded.repository.repositoryId, loaded.iid, threadId) + ) + ).data + ); + if ( + thread.id !== threadId || + thread.notes.some(note => String(note.noteable_id) !== intent.review.reviewId) + ) + throw new GitLabInteractiveError('forbidden'); + if (input.action !== 'reply') { + const access = Math.max( + loaded.project.permissions?.project_access?.access_level ?? 0, + loaded.project.permissions?.group_access?.access_level ?? 0 + ); + const resolvable = thread.notes.filter(note => note.resolvable); + if ( + !resolvable.length || + resolvable.some( + note => + !( + note.current_user?.can_resolve ?? + (overview.author?.id === auth.actor.id || access >= 30) + ) + ) + ) + throw new GitLabInteractiveError('forbidden'); + } + } + const deleting = input.deletion?.effect === 'delete'; + if (input.action === 'updateBranch' || input.action === 'deleteBranch' || deleting) { + const sourceBranch = overview.source.branch; + if ( + !overview.source.repository || + !sourceBranch || + (overview.source.repository.repositoryId === overview.target.repository.repositoryId && + sourceBranch === overview.target.branch) + ) + throw new GitLabInteractiveError('forbidden'); + const source = await resolveGitLabReviewProject( + auth, + overview.source.repository.repositoryId, + overview.source.repository + ); + if (input.action === 'deleteBranch' || deleting) { + const deletion = input.deletion; + if ( + !deletion || + deletion.effect !== 'delete' || + deletion.branch !== sourceBranch || + deletion.expectedHeadSha !== intent.revision.headSha || + deletion.repositoryKey !== + repositoryResourceKey(auth.userId, { + repository: source.repository, + authorization: auth.authorization, + }) + ) + throw new GitLabInteractiveError('forbidden'); + } + if (input.action === 'deleteBranch' ? overview.state !== 'merged' : overview.state !== 'open') + throw new GitLabInteractiveError('conflict'); + const branch = parseGitLab( + branchSchema, + ( + await source.client.execute(api => + api.Branches.show(source.repository.repositoryId, sourceBranch) + ) + ).data + ); + if (branch.name !== sourceBranch || branch.commit.id !== intent.revision.headSha) + throw new GitLabInteractiveError('conflict'); + if (!branch.can_push || (deleting && (branch.default || branch.protected))) + throw new GitLabInteractiveError('forbidden'); + return { ...loaded, source, sourceBranch }; + } + return { ...loaded, source: null, sourceBranch: null }; +} +function errorResult(error: unknown, dispatched: boolean): ReviewEffectResult { + if (error instanceof GitLabInteractiveError) { + // Only explicit provider 4xx rejections prove that a dispatched write did not commit. + if ( + !dispatched || + // These broker/request-budget errors occur before provider dispatch, including after preflight. + ['forbidden', 'not_connected', 'reconnect_required', 'request_too_large'].includes( + error.code + ) || + (error.status !== undefined && + [400, 401, 403, 404, 405, 406, 409, 422, 429].includes(error.status)) + ) + return rejectedReviewEffect( + error.code, + ['temporarily_unavailable', 'pagination_limit', 'response_too_large'].includes(error.code) + ? 'same-key' + : 'never' + ); + } + return dispatched + ? unresolvedReviewEffect('provider_outcome_unknown') + : rejectedReviewEffect('preflight_unavailable', 'same-key'); +} +async function perform(context: Context, effect: Effect): Promise { + const { + auth, + request: { intent }, + } = context; + const { input } = effect; + let dispatched = false; + let result: ReviewEffectResult | undefined; + try { + const loaded = await preflight(context, effect); + const { iid } = loaded; + let providerStatus: number | undefined; + async function receive(request: Promise): Promise { + const response = await request; + providerStatus = response.status; + return response; + } + const client: Pick = { + execute: operation => receive(loaded.client.execute(operation)), + requestChanges: number => receive(loaded.client.requestChanges(number)), + }; + const completed = (ref: ProviderReference): ReviewEffectResult => + providerStatus === 202 + ? pending(ref) + : providerStatus === undefined + ? unresolvedReviewEffect('receipt_missing', ref) + : confirmedReviewEffect(ref); + const projectId = loaded.repository.repositoryId; + let version: string | undefined; + if (input.action === 'enableAutoMerge') + version = parseGitLab( + z.object({ version: z.string().regex(/^\d+\.\d+\./) }), + (await client.execute(api => api.Metadata.show())).data + ).version; + if (effect.draft) { + const draft = parseGitLab( + z.object({ + id, + author_id: id, + merge_request_id: id, + discussion_id: z.string().nullable(), + resolve_discussion: z.boolean(), + note: z.string(), + position: positionSchema, + }), + ( + await client.execute(api => + api.MergeRequestDraftNotes.show(projectId, iid, numeric(effect.draft?.id ?? '')) + ) + ).data + ); + if ( + String(draft.id) !== effect.draft.id || + String(draft.author_id) !== auth.actor.id || + String(draft.merge_request_id) !== intent.review.reviewId || + // Native draft replies also resolve or reopen their stored discussion. + draft.discussion_id !== null || + draft.resolve_discussion || + draft.note !== input.body || + !input.position || + !matchesPosition(draft.position, input.position) + ) + throw new GitLabInteractiveError('conflict'); + } + if (input.action === 'removeReaction') { + const noteId = target(context, input, 'comment'); + const awards = await client.execute(api => + api.MergeRequestNoteAwardEmojis.all(projectId, iid, numeric(noteId), { + perPage: 100, + maxPages: 100, + }) + ); + if (awards.headers['x-next-page'] || /rel="next"/.test(awards.headers.link ?? '')) + throw new GitLabInteractiveError('pagination_limit'); + const award = parseGitLab(z.array(awardSchema), awards.data).find( + item => String(item.id) === input.reaction + ); + if (!award || String(award.user.id) !== auth.actor.id) + throw new GitLabInteractiveError('forbidden'); + } + // GitLab fences only source SHA for approval/merge. Inline positions attach to immutable refs. + // Rebase and deletion have preflight-only protection; neither API accepts an expected SHA. + const beforeDispatch = await snapshot(context); + sameRevision(intent.revision, beforeDispatch.revision); + if ( + (input.action === 'merge' || input.action === 'enableAutoMerge') && + (beforeDispatch.project.merge_method !== input.method || + beforeDispatch.project.squash_option !== loaded.project.squash_option) + ) + throw new GitLabInteractiveError('conflict'); + dispatched = true; + if (effect.draft) { + await client.execute(api => + api.MergeRequestDraftNotes.publish(projectId, iid, numeric(effect.draft?.id ?? '')) + ); + return completed(reference(context, 'comment', `draft:${effect.draft.id}`)); + } + switch (input.action) { + case 'comment': + case 'inlineComment': + case 'reply': { + const response = + input.action === 'inlineComment' && input.position + ? await client.execute(api => + api.MergeRequestDiscussions.create(projectId, iid, input.body ?? '', { + position: gitLabPosition(input.position as ReviewPosition, intent.revision), + }) + ) + : input.action === 'reply' + ? await client.execute(api => + api.MergeRequestDiscussions.addNote( + projectId, + iid, + target(context, input, 'thread'), + input.body ?? '' + ) + ) + : await client.execute(api => + api.MergeRequestNotes.create(projectId, iid, input.body ?? '', { + mergeRequestDiffSha: intent.revision.headSha, + }) + ); + const note = + input.action === 'inlineComment' + ? parseGitLab(threadSchema, response.data).notes[0] + : parseGitLab(noteSchema, response.data); + const ref = reference(context, 'comment', String(note.id)); + if ( + note.body !== input.body || + String(note.author?.id) !== auth.actor.id || + String(note.noteable_id) !== intent.review.reviewId || + (input.position && !matchesPosition(note.position, input.position)) + ) + return unresolvedReviewEffect('provider_evidence_mismatch', ref); + result = completed(ref); + if (input.action === 'inlineComment') return result; + break; + } + case 'resolveThread': + case 'reopenThread': { + const threadId = target(context, input, 'thread'); + const thread = parseGitLab( + threadSchema, + ( + await client.execute(api => + api.MergeRequestDiscussions.resolve( + projectId, + iid, + threadId, + input.action === 'resolveThread' + ) + ) + ).data + ); + if ( + thread.id !== threadId || + !thread.notes.some(note => note.resolvable) || + thread.notes.some( + note => note.resolvable && note.resolved !== (input.action === 'resolveThread') + ) + ) + return unresolvedReviewEffect( + 'resolution_unconfirmed', + reference(context, 'thread', threadId) + ); + result = completed(reference(context, 'thread', threadId)); + break; + } + case 'addReaction': { + const award = parseGitLab( + awardSchema, + ( + await client.execute(api => + api.MergeRequestNoteAwardEmojis.award( + projectId, + iid, + numeric(target(context, input, 'comment')), + input.reaction ?? '' + ) + ) + ).data + ); + const ref = reference(context, 'reaction', String(award.id)); + if (award.name !== input.reaction || String(award.user.id) !== auth.actor.id) + return unresolvedReviewEffect('actor_mismatch', ref); + result = completed(ref); + break; + } + case 'removeReaction': + await client.execute(api => + api.MergeRequestNoteAwardEmojis.remove( + projectId, + iid, + numeric(target(context, input, 'comment')), + numeric(input.reaction ?? '') + ) + ); + result = completed(reference(context, 'reaction', input.reaction ?? '')); + break; + case 'approve': { + const approval = parseGitLab( + z.object({ approved_by: z.array(z.object({ user: GitLabUserSchema })) }), + ( + await client.execute(api => + api.MergeRequestApprovals.approve(projectId, iid, { sha: intent.revision.headSha }) + ) + ).data + ); + if (!approval.approved_by.some(item => String(item.user.id) === auth.actor.id)) + return unresolvedReviewEffect('approval_unconfirmed'); + return completed(reference(context, 'review', intent.review.reviewId)); + } + case 'unapprove': + await client.execute(api => api.MergeRequestApprovals.unapprove(projectId, iid)); + result = completed(reference(context, 'review', intent.review.reviewId)); + break; + case 'requestChanges': { + // This state mutation does not require licensed merge blocking. + const response = parseGitLab( + z.object({ + data: z + .object({ + mergeRequestRequestChanges: z + .object({ + errors: z.array(z.string()), + mergeRequest: z.object({ id: z.string(), iid: z.string() }).nullable(), + }) + .nullable(), + }) + .nullable() + .optional(), + errors: z.array(z.unknown()).optional(), + }), + (await client.requestChanges(iid)).data + ); + const payload = response.data?.mergeRequestRequestChanges; + if (response.errors?.length || !payload) + return unresolvedReviewEffect('request_changes_unconfirmed'); + if (payload.errors.length && !payload.mergeRequest) + return rejectedReviewEffect('request_changes_rejected'); + if ( + payload.errors.length || + payload.mergeRequest?.id !== `gid://gitlab/MergeRequest/${intent.review.reviewId}` || + payload.mergeRequest.iid !== intent.review.number + ) + return unresolvedReviewEffect('request_changes_unconfirmed'); + result = completed(reference(context, 'review', intent.review.reviewId)); + break; + } + case 'merge': + case 'enableAutoMerge': { + const [major, minor] = (version ?? '0.0').split('.').map(Number); + const auto = input.action === 'enableAutoMerge'; + const message = [input.commitTitle, input.commitMessage] + .filter(value => value !== undefined) + .join('\n\n'); + const response = await client.execute(api => + api.MergeRequests.merge(projectId, iid, { + sha: intent.revision.headSha, + shouldRemoveSourceBranch: false, + squash: + input.squash ?? ['always', 'default_on'].includes(loaded.project.squash_option ?? ''), + ...(message ? { mergeCommitMessage: message, squashCommitMessage: message } : {}), + // Old pre-17.11 instances require this form. Remove it only after those instances, + // old clients/records, and the 30-day ledger window no longer need it. + ...(auto + ? major > 17 || (major === 17 && minor >= 11) + ? { autoMerge: true } + : { mergeWhenPipelineSucceeds: true } + : {}), + }) + ); + const ref = reference(context, 'review', intent.review.reviewId); + result = pending(ref); + const current = await snapshot(context); + if (current.review.sha !== intent.revision.headSha) + return unresolvedReviewEffect('source_changed', ref); + if (current.review.state === 'merged') return confirmedReviewEffect(ref); + if ( + auto && + (current.review.merge_when_pipeline_succeeds || current.review.auto_merge_enabled) + ) + return confirmedReviewEffect(ref); + return response.status === 202 + ? pending(ref) + : unresolvedReviewEffect('merge_unconfirmed', ref); + } + case 'disableAutoMerge': + await client.execute(api => api.MergeRequests.cancelOnPipelineSuccess(projectId, iid)); + return reconcile( + context, + effect, + pending(reference(context, 'review', intent.review.reviewId)) + ); + case 'updateBranch': { + const response = await client.execute(api => api.MergeRequests.rebase(projectId, iid)); + parseGitLab(z.object({ rebase_in_progress: z.boolean() }), response.data); + const ref = reference(context, 'review', intent.review.reviewId); + return response.status === 202 + ? pending(ref) + : unresolvedReviewEffect('rebase_acceptance_unknown', ref); + } + case 'deleteBranch': { + const { source, sourceBranch } = loaded; + if (!source || !sourceBranch) return unresolvedReviewEffect('source_unavailable'); + await receive( + source.client.execute(api => + api.Branches.remove(source.repository.repositoryId, sourceBranch) + ) + ); + return completed(reference(context, 'review', intent.review.reviewId)); + } + default: + return rejectedReviewEffect('invalid_action'); + } + const current = await snapshot(context); + try { + sameRevision(intent.revision, current.revision); + } catch { + return unresolvedReviewEffect( + 'revision_changed_after_write', + 'reference' in result ? result.reference : null + ); + } + return result; + } catch (error) { + return result && 'reference' in result + ? unresolvedReviewEffect('postflight_unavailable', result.reference) + : errorResult(error, dispatched); + } +} +async function reconcile( + context: Context, + effect: Effect, + stored: ReviewEffectResult | null +): Promise { + const ref = stored && 'reference' in stored ? stored.reference : null; + const { + auth, + request: { intent }, + } = context; + const { input } = effect; + try { + if ( + ref && + (ref.provider !== 'gitlab' || (ref.url !== null && ref.url !== intent.review.canonicalUrl)) + ) + return unresolvedReviewEffect('receipt_identity_mismatch'); + const current = await snapshot(context); + const { client, iid } = current; + const projectId = current.repository.repositoryId; + if ( + (input.action === 'merge' || input.action === 'enableAutoMerge') && + current.review.state === 'merged' && + current.review.sha === intent.revision.headSha + ) + return confirmedReviewEffect(reference(context, 'review', intent.review.reviewId)); + if (input.action === 'merge') + return stored?.status === 'accepted' + ? stored + : unresolvedReviewEffect('merge_unconfirmed', ref); + if ( + input.action === 'updateBranch' && + stored?.status === 'accepted' && + ref?.kind === 'review' && + ref.id === intent.review.reviewId + ) { + if (current.review.rebase_in_progress) return stored; + if (current.review.merge_error) return rejectedReviewEffect('rebase_failed'); + if (current.review.rebase_in_progress === false && current.review.merge_error === null) + return confirmedReviewEffect(ref); + } + if (input.action !== 'inlineComment') sameRevision(intent.revision, current.revision); + if (input.action === 'disableAutoMerge' || input.action === 'enableAutoMerge') { + const enabled = + current.review.auto_merge_enabled ?? current.review.merge_when_pipeline_succeeds; + if (enabled === (input.action === 'enableAutoMerge')) + return confirmedReviewEffect(reference(context, 'review', intent.review.reviewId)); + } + if ( + input.action === 'deleteBranch' && + input.deletion?.effect === 'delete' && + current.review.state === 'merged' && + current.review.source_project_id !== null + ) { + const deletion = input.deletion; + const source = await resolveGitLabReviewProject( + auth, + String(current.review.source_project_id) + ); + if ( + deletion.expectedHeadSha !== intent.revision.headSha || + deletion.branch !== current.review.source_branch || + deletion.branch === source.repository.defaultBranch || + deletion.repositoryKey !== + repositoryResourceKey(auth.userId, { + repository: source.repository, + authorization: auth.authorization, + }) + ) + return unresolvedReviewEffect('source_identity_mismatch', ref); + try { + await source.client.execute(api => + api.Branches.show(source.repository.repositoryId, deletion.branch) + ); + } catch (error) { + if (error instanceof GitLabInteractiveError && error.status === 404) + return confirmedReviewEffect(reference(context, 'review', intent.review.reviewId)); + throw error; + } + } + // Without a receipt, an absent note cannot prove non-execution. Draft disappearance does not prove publication. + if (!ref || effect.draft) return unresolvedReviewEffect('receipt_missing', ref); + if (['comment', 'inlineComment', 'reply'].includes(input.action) && ref.kind === 'comment') { + const note = + input.action === 'reply' + ? parseGitLab( + threadSchema, + ( + await client.execute(api => + api.MergeRequestDiscussions.show(projectId, iid, target(context, input, 'thread')) + ) + ).data + ).notes.find(item => String(item.id) === ref.id) + : parseGitLab( + noteSchema, + ( + await client.execute(api => + api.MergeRequestNotes.show(projectId, iid, numeric(ref.id)) + ) + ).data + ); + if ( + note && + String(note.id) === ref.id && + note.body === input.body && + String(note.author?.id) === auth.actor.id && + String(note.noteable_id) === intent.review.reviewId && + (!input.position || matchesPosition(note.position, input.position)) + ) + return confirmedReviewEffect(ref); + } + if ( + (input.action === 'resolveThread' || input.action === 'reopenThread') && + ref.kind === 'thread' && + ref.id === target(context, input, 'thread') + ) { + const thread = parseGitLab( + threadSchema, + (await client.execute(api => api.MergeRequestDiscussions.show(projectId, iid, ref.id))).data + ); + const notes = thread.notes.filter(note => note.resolvable); + if ( + thread.id === ref.id && + notes.length && + notes.every( + note => + String(note.noteable_id) === intent.review.reviewId && + note.resolved === (input.action === 'resolveThread') + ) + ) + return confirmedReviewEffect(ref); + } + if ( + (input.action === 'addReaction' || input.action === 'removeReaction') && + ref.kind === 'reaction' + ) { + const response = await client.execute(api => + api.MergeRequestNoteAwardEmojis.all( + projectId, + iid, + numeric(target(context, input, 'comment')), + { perPage: 100, maxPages: 100 } + ) + ); + if (response.headers['x-next-page'] || /rel="next"/.test(response.headers.link ?? '')) + throw new GitLabInteractiveError('pagination_limit'); + const award = parseGitLab(z.array(awardSchema), response.data).find( + value => String(value.id) === ref.id + ); + if ( + input.action === 'removeReaction' + ? !award && ref.id === input.reaction + : award && award.name === input.reaction && String(award.user.id) === auth.actor.id + ) + return confirmedReviewEffect(ref); + } + if ( + (input.action === 'approve' || input.action === 'unapprove') && + ref.kind === 'review' && + ref.id === intent.review.reviewId + ) { + const approvals = parseGitLab( + z.object({ approved_by: z.array(z.object({ user: GitLabUserSchema })) }), + ( + await client.execute(api => + api.MergeRequestApprovals.showConfiguration(projectId, { mergerequestIId: iid }) + ) + ).data + ); + if ( + approvals.approved_by.some(value => String(value.user.id) === auth.actor.id) === + (input.action === 'approve') + ) + return confirmedReviewEffect(ref); + } + if ( + input.action === 'requestChanges' && + ref.kind === 'review' && + ref.id === intent.review.reviewId + ) { + const reviewers = parseGitLab( + z.array(z.object({ user: GitLabUserSchema, state: z.string() })), + (await client.execute(api => api.MergeRequests.showReviewers(projectId, iid))).data + ); + if ( + reviewers.some( + value => String(value.user.id) === auth.actor.id && value.state === 'requested_changes' + ) + ) + return confirmedReviewEffect(ref); + } + return unresolvedReviewEffect('provider_outcome_unknown', ref); + } catch { + return unresolvedReviewEffect('reconciliation_unavailable', ref); + } +} +function effects(input: ReviewIntentInput): Effect[] { + if (input.action !== 'submitReview') + return [ + { itemId: input.action, input }, + ...(input.deletion?.effect === 'delete' && input.action === 'merge' + ? [ + { + itemId: 'deleteBranch', + input: { action: 'deleteBranch' as const, deletion: input.deletion }, + }, + ] + : []), + ]; + const comments = input.comments ?? []; + const drafts = input.draftReferences ?? []; + if ( + comments.length > 100 || + new Set(comments.map(item => item.itemId)).size !== comments.length || + new Set(drafts.map(item => item.id)).size !== drafts.length || + drafts.some( + draft => + draft.provider !== 'gitlab' || + draft.kind !== 'comment' || + !comments.some(comment => comment.itemId === draft.id) + ) + ) + throw new GitLabInteractiveError('invalid_request'); + return [ + ...comments.map(comment => ({ + itemId: `comment:${comment.itemId}`, + input: { action: 'inlineComment' as const, body: comment.body, position: comment.position }, + draft: drafts.find(draft => draft.id === comment.itemId), + })), + ...(input.body + ? [{ itemId: 'summary', input: { action: 'comment' as const, body: input.body } }] + : []), + ...(input.choice && input.choice !== 'comment' + ? [{ itemId: 'decision', input: { action: input.choice } }] + : []), + ]; +} + +// Action-specific fields prevent silently dropping editable work or a destructive choice. +const actionFields: Partial> = { + comment: ['body'], + inlineComment: ['body', 'position'], + reply: ['body', 'target'], + resolveThread: ['target'], + reopenThread: ['target'], + addReaction: ['target', 'reaction'], + removeReaction: ['target', 'reaction'], + approve: [], + unapprove: [], + requestChanges: [], + updateBranch: [], + disableAutoMerge: [], + merge: ['method', 'squash', 'commitTitle', 'commitMessage', 'deletion'], + enableAutoMerge: ['method', 'squash', 'commitTitle', 'commitMessage'], + deleteBranch: ['deletion'], + submitReview: ['comments', 'draftReferences', 'body', 'choice'], +}; + +/** Supply authorizeGitLabReview authorization for every write and status request, never client-created credentials. */ +export async function runGitLabReviewOperation( + auth: GitLabReviewAuthorization, + request: ReviewOperationRequest, + statusOnly = false +): Promise { + const { intent } = request; + if ( + request.effect || + auth.userId !== request.userId || + intent.accountId !== auth.userId || + intent.actorId !== auth.actor.id || + intent.review.repository.provider !== 'gitlab' || + repositoryResourceKey(auth.userId, intent.review) !== + repositoryResourceKey(auth.userId, { + repository: intent.review.repository, + authorization: auth.authorization, + }) + ) + return rejectedReviewEffect('operation_identity_mismatch'); + serializeReviewWriteRequest(intent); + const input = parseGitLab(ReviewIntentInputSchema, intent.input, 'invalid_request'); + const fields = actionFields[input.action]; + if (!fields || Object.keys(input).some(key => key !== 'action' && !fields.includes(key))) + throw new GitLabInteractiveError('invalid_request'); + const selected = parseGitLab(ReviewRevisionSchema, intent.revision, 'invalid_request'); + parseGitLab(sha, selected.headSha, 'invalid_request'); + const context = { auth, request }; + const list = effects(input); + for (const effect of list) { + parseGitLab(z.string().min(1).max(512), effect.itemId, 'invalid_request'); + const value = effect.input; + if (['comment', 'inlineComment', 'reply'].includes(value.action) && !value.body?.trim()) + throw new GitLabInteractiveError('invalid_request'); + if (value.action === 'inlineComment' && !value.position) + throw new GitLabInteractiveError('invalid_request'); + if (value.position) gitLabPosition(value.position, selected); + if (['reply', 'resolveThread', 'reopenThread'].includes(value.action)) + target(context, value, 'thread'); + if (value.action === 'addReaction' || value.action === 'removeReaction') { + numeric(target(context, value, 'comment')); + parseGitLab(z.string().min(1), value.reaction, 'invalid_request'); + if (value.action === 'removeReaction') numeric(value.reaction ?? ''); + } + if (effect.draft) { + numeric(effect.draft.id); + if ( + effect.draft.url !== null && + effect.draft.url.split('#')[0] !== intent.review.canonicalUrl + ) + throw new GitLabInteractiveError('invalid_request'); + } + } + if (input.action !== 'submitReview' && list.length === 1) + return runReviewOperation(request, { + ...(statusOnly ? {} : { execute: () => perform(context, list[0]) }), + reconcile: stored => reconcile(context, list[0], stored), + }); + const items: Extract['items'] = []; + async function publish(): Promise { + let stopped = false; + for (const effect of list) { + const result: ReviewEffectResult = stopped + ? rejectedReviewEffect('previous_effect_unconfirmed', 'same-key') + : await runReviewOperation( + { ...request, effect: { id: effect.itemId, action: effect.input.action } }, + { + ...(statusOnly ? {} : { execute: () => perform(context, effect) }), + reconcile: stored => reconcile(context, effect, stored), + } + ); + items.push({ itemId: effect.itemId, effect: effect.input.action, result }); + stopped ||= result.status !== 'confirmed'; + } + return stopped + ? unresolvedReviewEffect('batch_incomplete') + : confirmedReviewEffect( + list.length ? reference(context, 'review', intent.review.reviewId) : null + ); + } + // The parent binds the whole batch before any child admission. Only compact parent status is stored. + const result = await runReviewOperation(request, { + ...(statusOnly ? {} : { execute: publish }), + reconcile: publish, + aggregate: true, + }); + return items.some(item => item.result.status !== 'confirmed') + ? { status: 'partial', items, retry: 'unfinished-only', reconciliation: 'required' } + : result; +} diff --git a/apps/web/src/lib/provider-review/operation.test.ts b/apps/web/src/lib/provider-review/operation.test.ts new file mode 100644 index 0000000000..39342b54a2 --- /dev/null +++ b/apps/web/src/lib/provider-review/operation.test.ts @@ -0,0 +1,526 @@ +jest.mock('@/lib/drizzle', () => ({ db: { select: jest.fn() } })); +jest.mock('@kilocode/db/operation-ledger', () => ({ + ...jest.requireActual('@kilocode/db/operation-ledger'), + admitOperation: jest.fn(), + recordOperationProgress: jest.fn(), + recordOperationAcceptance: jest.fn(), + settleOperation: jest.fn(), + markReconcilePending: jest.fn(), +})); + +import { PgDialect } from 'drizzle-orm/pg-core'; +import { db } from '@/lib/drizzle'; +import { user_terms_acceptances, type OperationLedgerRow } from '@kilocode/db/schema'; +import { + admitOperation, + recordOperationProgress, + recordOperationAcceptance, + settleOperation, + markReconcilePending, + type OutboxEventInput, +} from '@kilocode/db/operation-ledger'; +import { ANALYTICS_EVENT_SCHEMAS } from '@kilocode/app-shared/analytics'; +import { CURRENT_UGC_TERMS_VERSION } from '@kilocode/app-shared/moderation'; +import { providerReviewFixtures } from '@kilocode/app-shared/provider-review/fixtures'; +import { + confirmedReviewEffect, + rejectedReviewEffect, + unresolvedReviewEffect, + runReviewOperation, + type ReviewOperationRequest, + type ReviewEffectResult, +} from './operation'; + +const userId = 'oauth/reviewer'; +const request: ReviewOperationRequest = { + userId, + distinctId: 'reviewer@example.com', + operationKey: '11111111-1111-4111-8111-111111111111', + intent: { + accountId: userId, + actorId: '9', + review: { + ...providerReviewFixtures.gitlab.user, + authorization: { + kind: 'ownerIntegration', + owner: { type: 'user', id: userId }, + integrationId: 'integration', + }, + }, + revision: { + headSha: 'a'.repeat(40), + baseSha: 'b'.repeat(40), + startSha: 'c'.repeat(40), + targetHeadSha: null, + }, + input: { action: 'comment', body: 'Private review text' }, + }, +}; +const ref = { provider: 'gitlab' as const, kind: 'comment' as const, id: '42', url: null }; +let rows: Map; +let outbox: OutboxEventInput[]; +let effects: string[]; +let terms: boolean; +const rowKey = (user: string, key: string) => `${user}:${key}`; +const row = () => [...rows.values()][0]; +function recorded(rowId: string) { + const value = [...rows.values()].find(item => item.id === rowId); + if (!value) throw new Error('Missing test row'); + return value; +} +const execute = async () => { + effects.push('comment'); + return confirmedReviewEffect(ref); +}; +const reconcile = async () => unresolvedReviewEffect('receipt_missing'); +const run = (input = request) => runReviewOperation(input, { execute, reconcile }); + +beforeEach(() => { + jest.resetAllMocks(); + rows = new Map(); + outbox = []; + effects = []; + terms = true; + jest.mocked(db.select).mockImplementation( + () => + ({ + from: (table: unknown) => ({ + where: (where: Parameters[0]) => ({ + limit: async () => { + const parameters = new PgDialect().sqlToQuery(where).params; + if (table === user_terms_acceptances) + return terms && + parameters[0] === userId && + parameters[1] === CURRENT_UGC_TERMS_VERSION + ? [{ id: 'acceptance' }] + : []; + return [...rows.values()].filter( + value => + value.kilo_user_id === parameters[0] && + value.domain === parameters[1] && + value.operation_key === parameters[2] + ); + }, + }), + }), + }) as any + ); + jest.mocked(admitOperation).mockImplementation(async (_db, input) => { + const key = rowKey(input.userId, input.operationKey), + existing = rows.get(key); + if (existing) { + if (['completed', 'failed'].includes(existing.status)) + return { admission: 'duplicate_settled', row: existing }; + const live = new Date(existing.lease_expires_at).getTime() > Date.now(); + if (!live) existing.lease_expires_at = new Date(Date.now() + 60_000).toISOString(); + return { + admission: + existing.status === 'admitted' + ? live + ? 'duplicate_in_flight' + : 'takeover' + : live + ? 'duplicate_reconcile_in_progress' + : 'duplicate_reconcile_pending', + row: { ...existing }, + }; + } + const fresh: OperationLedgerRow = { + id: String(rows.size + 1), + kilo_user_id: input.userId, + organization_id: input.orgId ?? null, + domain: input.domain, + intent: input.intent, + operation_key: input.operationKey, + resource_key: input.resourceKey ?? null, + taxonomy: input.taxonomy, + status: 'admitted', + canonical_result: null, + provider_ref: null, + outcome_code: null, + settled_at: null, + admitted_at: '2026-08-30 01:00:00.000+00', + expires_at: '2026-09-30 01:00:00.000+00', + lease_expires_at: new Date(Date.now() + 60_000).toISOString(), + }; + rows.set(key, fresh); + return { admission: 'admitted', row: { ...fresh } }; + }); + jest.mocked(recordOperationProgress).mockImplementation(async (_db, id, patch) => { + const value = recorded(id); + value.canonical_result = { ...value.canonical_result, ...patch }; + return value; + }); + jest.mocked(recordOperationAcceptance).mockImplementation(async (_db, input) => { + const value = recorded(input.rowId); + value.canonical_result = { ...value.canonical_result, ...input.canonicalResult }; + value.provider_ref = input.providerRef; + return value; + }); + jest.mocked(settleOperation).mockImplementation(async (_db, input) => { + const value = recorded(input.rowId); + if (value.status === 'completed' || value.status === 'failed') + return { settled: false, row: value }; + if (input.outboxEvent) { + ANALYTICS_EVENT_SCHEMAS[input.outboxEvent.eventName].parse(input.outboxEvent.properties); + outbox.push(input.outboxEvent); + } + Object.assign(value, { + status: input.status, + outcome_code: input.outcomeCode, + canonical_result: { ...value.canonical_result, ...input.canonicalResult }, + }); + return { settled: true, row: value }; + }); + jest.mocked(markReconcilePending).mockImplementation(async (_db, input) => { + const value = recorded(input.rowId); + if (value.status === 'admitted') { + value.status = 'reconcile_pending'; + value.lease_expires_at = new Date(0).toISOString(); + if (input.outboxEvent) { + ANALYTICS_EVENT_SCHEMAS[input.outboxEvent.eventName].parse(input.outboxEvent.properties); + outbox.push(input.outboxEvent); + } + } + return value; + }); +}); + +it('AC6 admits concurrent same-key calls once and replays the confirmed result', async () => { + const gate = Promise.withResolvers(); + const first = runReviewOperation(request, { + execute: async () => { + effects.push('comment'); + await gate.promise; + return confirmedReviewEffect(ref); + }, + reconcile, + }); + await Promise.resolve(); + await Promise.resolve(); + const duplicate = await run(); + expect(duplicate).toMatchObject({ + status: 'unresolved', + reason: 'operation_in_progress', + retry: 'reconcile', + }); + gate.resolve(); + expect(await first).toMatchObject({ status: 'confirmed', reference: ref }); + expect(await run()).toEqual(await first); + expect(effects).toEqual(['comment']); + expect(rows.size).toBe(1); + expect(outbox).toHaveLength(1); +}); + +it.each([ + 'actor', + 'owner', + 'integration', + 'instance', + 'repository', + 'review', + 'head', + 'body', + 'action', +] as const)( + 'AC6/AC10 refuses same-key %s replacement without another provider effect', + async field => { + await run(); + const changed = structuredClone(request); + switch (field) { + case 'actor': + changed.intent.actorId = 'another-actor'; + break; + case 'owner': + changed.intent.review.authorization = { + kind: 'ownerIntegration', + owner: { type: 'org', id: 'another-org' }, + integrationId: 'integration', + }; + break; + case 'integration': + if (changed.intent.review.authorization.kind === 'ownerIntegration') + changed.intent.review.authorization.integrationId = 'other'; + break; + case 'instance': + changed.intent.review.repository.instanceUrl = 'https://other.example/GitLab'; + break; + case 'repository': + changed.intent.review.repository.repositoryId = 'another'; + break; + case 'review': + changed.intent.review.reviewId = 'another'; + break; + case 'head': + changed.intent.revision.headSha = 'd'.repeat(40); + break; + case 'body': + changed.intent.input.body = 'Changed text'; + break; + case 'action': + changed.intent.input = { action: 'approve' }; + break; + } + expect(await run(changed)).toMatchObject({ + status: 'rejected', + code: 'operation_key_reuse_mismatch', + retry: 'never', + }); + expect(effects).toEqual(['comment']); + expect(outbox).toHaveLength(1); + } +); + +it('AC6 retains Terms acceptance before content admission', async () => { + terms = false; + await expect(run()).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: 'terms_required', + }); + expect(rows.size).toBe(0); + expect(effects).toEqual([]); +}); +it('AC6 refuses a different caller before admission', async () => { + expect(await run({ ...request, userId: 'someone-else' })).toMatchObject({ + status: 'rejected', + code: 'operation_identity_mismatch', + }); + expect(rows.size).toBe(0); + expect(effects).toEqual([]); +}); +it('AC10 keeps the new helper away from the legacy GitHub namespace', async () => { + const github = { + ...request, + intent: { ...request.intent, review: providerReviewFixtures.github.user }, + }; + expect(await run(github)).toMatchObject({ status: 'rejected' }); + expect(rows.size).toBe(0); + expect(effects).toEqual([]); +}); +it('AC6 never retries a provider write after a lost response', async () => { + const lost = async () => { + effects.push('comment'); + throw new Error('secret provider response'); + }; + const first = await runReviewOperation(request, { execute: lost, reconcile }); + expect(first).toMatchObject({ status: 'unresolved', retry: 'reconcile' }); + expect(JSON.stringify(first)).not.toContain('secret'); + expect(await run()).toMatchObject({ status: 'unresolved' }); + row().lease_expires_at = new Date(0).toISOString(); + expect(await run()).toMatchObject({ status: 'unresolved' }); + expect(effects).toEqual(['comment']); + expect(row().status).toBe('reconcile_pending'); +}); +it('AC6 forbids takeover replay when acceptance and pending persistence both fail', async () => { + jest.mocked(recordOperationAcceptance).mockRejectedValueOnce(new Error('offline')); + jest.mocked(markReconcilePending).mockRejectedValueOnce(new Error('offline')); + expect(await run()).toMatchObject({ status: 'unresolved', reason: 'ledger_persistence_failed' }); + expect(row().provider_ref).toBeNull(); + row().lease_expires_at = new Date(0).toISOString(); + expect(await run()).toMatchObject({ status: 'unresolved' }); + expect(effects).toEqual(['comment']); +}); +it('AC6 settles a recorded receipt after outbox failure without repeating the effect', async () => { + jest.mocked(settleOperation).mockRejectedValueOnce(new Error('outbox unavailable')); + expect(await run()).toMatchObject({ status: 'unresolved', reason: 'ledger_persistence_failed' }); + expect(row().provider_ref).toBe(JSON.stringify(ref)); + expect(outbox).toEqual([]); + expect(await run()).toMatchObject({ status: 'confirmed', reference: ref }); + expect(effects).toEqual(['comment']); + expect(row().status).toBe('completed'); + expect(outbox).toHaveLength(1); +}); +it('AC6 permits only an explicitly persisted pre-dispatch retry', async () => { + const denied = await runReviewOperation(request, { + execute: async () => rejectedReviewEffect('preflight_unavailable', 'same-key'), + reconcile, + }); + expect(denied).toMatchObject({ status: 'rejected', retry: 'same-key' }); + expect(effects).toEqual([]); + expect(await run()).toMatchObject({ status: 'confirmed' }); + expect(effects).toEqual(['comment']); +}); +it('AC7 serializes reconciliation and preserves accepted task progress', async () => { + const accepted: ReviewEffectResult = { + status: 'accepted', + reference: ref, + task: null, + retry: 'reconcile', + reconciliation: 'pending', + }; + await runReviewOperation(request, { + execute: async () => { + effects.push('task'); + return accepted; + }, + reconcile, + }); + const gate = Promise.withResolvers(); + const first = runReviewOperation(request, { + reconcile: async () => { + await gate.promise; + return confirmedReviewEffect(ref); + }, + }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + const second = await runReviewOperation(request, { reconcile }); + expect(second).toEqual(accepted); + gate.resolve(); + expect(await first).toMatchObject({ status: 'confirmed' }); + expect(effects).toEqual(['task']); + expect(outbox).toHaveLength(1); +}); +it.each(['confirmed', 'rejected'] as const)( + 'AC7 preserves accepted evidence across failed reads and reconstruction until %s', + async outcome => { + const rebase = { + ...request, + intent: { ...request.intent, input: { action: 'updateBranch' as const } }, + }; + const accepted: ReviewEffectResult = { + status: 'accepted', + reference: { ...ref, kind: 'review', id: '77' }, + task: null, + retry: 'reconcile', + reconciliation: 'pending', + }; + const execute = async () => { + effects.push('rebase'); + return accepted; + }; + expect(await runReviewOperation(rebase, { execute, reconcile })).toEqual(accepted); + expect( + await runReviewOperation(rebase, { + execute, + reconcile: async stored => + unresolvedReviewEffect( + 'reconciliation_unavailable', + stored && 'reference' in stored ? stored.reference : null + ), + }) + ).toMatchObject({ status: 'unresolved', retry: 'reconcile' }); + + // Reconstruct the process from serialized ledger rows, not the previous handler's memory. + rows = new Map(JSON.parse(JSON.stringify([...rows])) as [string, OperationLedgerRow][]); + let reconstructedRun = runReviewOperation; + jest.isolateModules(() => { + reconstructedRun = jest.requireActual<{ runReviewOperation: typeof runReviewOperation }>( + './operation' + ).runReviewOperation; + }); + row().lease_expires_at = new Date(0).toISOString(); + expect( + await reconstructedRun(rebase, { + execute, + reconcile: async () => { + throw new Error('Status read unavailable'); + }, + }) + ).toMatchObject({ status: 'unresolved', retry: 'reconcile' }); + expect(row().canonical_result).toEqual({ result: accepted }); + expect(row().provider_ref).toBe(JSON.stringify(accepted.reference)); + + row().lease_expires_at = new Date(0).toISOString(); + const terminal = + outcome === 'confirmed' + ? confirmedReviewEffect(accepted.reference) + : rejectedReviewEffect('rebase_failed'); + const recovered = await reconstructedRun(rebase, { + execute, + reconcile: async stored => + stored?.status === 'accepted' ? terminal : unresolvedReviewEffect('acceptance_missing'), + }); + expect(recovered).toEqual(terminal); + expect(await reconstructedRun(rebase, { execute, reconcile })).toEqual(terminal); + expect(row().status).toBe(outcome === 'confirmed' ? 'completed' : 'failed'); + expect(effects).toEqual(['rebase']); + } +); +it('AC6 status for an empty ledger never creates an operation or a provider effect', async () => { + expect(await runReviewOperation(request, { reconcile })).toMatchObject({ + status: 'rejected', + code: 'operation_not_admitted', + retry: 'same-key', + }); + expect(rows.size).toBe(0); + expect(effects).toEqual([]); +}); +it('AC6 keeps large comment bodies out of canonical results and analytics', async () => { + await run({ + ...request, + intent: { ...request.intent, input: { action: 'comment', body: 'Private '.repeat(10_000) } }, + }); + expect(Buffer.byteLength(JSON.stringify(row().canonical_result))).toBeLessThan(4096); + expect(JSON.stringify([row().canonical_result, outbox])).not.toContain('Private'); + expect(outbox[0].properties).toMatchObject({ + intent: 'create_review_comment', + outcome: 'completed', + }); +}); +it('AC6 rejects an oversized request before provider access', async () => { + await expect( + run({ + ...request, + intent: { ...request.intent, input: { action: 'comment', body: 'x'.repeat(256_000) } }, + }) + ).rejects.toThrow('serialized byte limit'); + expect(rows.size).toBe(0); + expect(effects).toEqual([]); +}); +it('AC6 keeps an oversized provider receipt unresolved instead of storing an invalid result', async () => { + expect( + await runReviewOperation(request, { + execute: async () => { + effects.push('comment'); + return confirmedReviewEffect({ ...ref, id: 'x'.repeat(5000) }); + }, + reconcile, + }) + ).toMatchObject({ status: 'unresolved', reason: 'result_too_large' }); + expect(Buffer.byteLength(JSON.stringify(row().canonical_result))).toBeLessThan(4096); + expect(await run()).toMatchObject({ status: 'unresolved' }); + expect(effects).toEqual(['comment']); +}); +it('AC6 retires a safe retry before a later dispatch loses its response', async () => { + await runReviewOperation(request, { + execute: async () => rejectedReviewEffect('preflight_unavailable', 'same-key'), + reconcile, + }); + expect( + await runReviewOperation(request, { + execute: async () => { + effects.push('comment'); + throw new Error('lost response'); + }, + reconcile, + }) + ).toMatchObject({ status: 'unresolved' }); + row().lease_expires_at = new Date(0).toISOString(); + expect(await run()).toMatchObject({ status: 'unresolved', retry: 'reconcile' }); + expect(effects).toEqual(['comment']); +}); +it('AC6 emits no duplicate provider outcome for an aggregate row', async () => { + expect(await runReviewOperation(request, { execute, reconcile, aggregate: true })).toMatchObject({ + status: 'confirmed', + }); + expect(effects).toEqual(['comment']); + expect(outbox).toEqual([]); + expect(row().status).toBe('completed'); +}); +it('AC10 keeps effect fingerprints stable across object field order', async () => { + expect(await run({ ...request, effect: { id: 'item', action: 'comment' } })).toMatchObject({ + status: 'confirmed', + }); + expect(await run({ ...request, effect: { action: 'comment', id: 'item' } })).toMatchObject({ + status: 'confirmed', + }); + expect(effects).toEqual(['comment']); + expect(rows.size).toBe(1); +}); +it('AC6 blocks dispatch when the durable dispatch fence cannot persist', async () => { + jest.mocked(recordOperationProgress).mockRejectedValueOnce(new Error('offline')); + expect(await run()).toMatchObject({ status: 'unresolved', retry: 'reconcile' }); + expect(effects).toEqual([]); +}); diff --git a/apps/web/src/lib/provider-review/operation.ts b/apps/web/src/lib/provider-review/operation.ts new file mode 100644 index 0000000000..adc284d7a0 --- /dev/null +++ b/apps/web/src/lib/provider-review/operation.ts @@ -0,0 +1,286 @@ +import 'server-only'; + +import { createHash } from 'node:crypto'; +import { and, eq } from 'drizzle-orm'; +import { TRPCError } from '@trpc/server'; +import { v5 as uuidv5 } from 'uuid'; +import { z } from 'zod'; +import { CURRENT_UGC_TERMS_VERSION } from '@kilocode/app-shared/moderation'; +import { PR_OPERATION_SETTLED_EVENT } from '@kilocode/app-shared/analytics'; +import { + ReviewEffectResultSchema, + providerReviewIntentFingerprint, + serializeReviewWriteRequest, + type ProviderReference, + type ReviewAction, + type ReviewIntent, +} from '@kilocode/app-shared/provider-review'; +import { + admitOperation, + markReconcilePending, + recordOperationAcceptance, + recordOperationProgress, + settleOperation, + MAX_CANONICAL_RESULT_BYTES, + type OutboxEventInput, +} from '@kilocode/db/operation-ledger'; +import { operation_ledgers, user_terms_acceptances } from '@kilocode/db/schema'; +import { db } from '@/lib/drizzle'; + +export type ReviewEffectResult = z.infer; +export type ReviewOperationRequest = { + userId: string; + distinctId: string; + operationKey: string; + intent: ReviewIntent; + effect?: { id: string; action: Exclude }; +}; +export function rejectedReviewEffect( + code: string, + retry: 'same-key' | 'never' = 'never' +): ReviewEffectResult { + return { status: 'rejected', code, explanation: code, retry, reconciliation: 'not-needed' }; +} +export function unresolvedReviewEffect( + reason: string, + reference: ProviderReference | null = null +): ReviewEffectResult { + return { + status: 'unresolved', + reference, + reason, + retry: 'reconcile', + reconciliation: 'required', + }; +} +export function confirmedReviewEffect(reference: ProviderReference | null): ReviewEffectResult { + return { status: 'confirmed', reference, retry: 'never', reconciliation: 'complete' }; +} + +export async function assertReviewTermsAccepted(userId: string): Promise { + const [accepted] = await db + .select({ id: user_terms_acceptances.id }) + .from(user_terms_acceptances) + .where( + and( + eq(user_terms_acceptances.kilo_user_id, userId), + eq(user_terms_acceptances.terms_version, CURRENT_UGC_TERMS_VERSION) + ) + ) + .limit(1); + if (!accepted) throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'terms_required' }); +} + +// The parent UUID and stable effect ID survive partial publication and client restarts. +export function reviewEffectOperationKey(operationKey: string, effectId?: string): string { + const key = z.uuid().parse(operationKey); + return effectId === undefined ? key : uuidv5(z.string().min(1).max(512).parse(effectId), key); +} + +export async function runReviewOperation( + request: ReviewOperationRequest, + handlers: { + // Omission selects status-only reconciliation, never a new provider write. + execute?: () => Promise; + reconcile: (stored: ReviewEffectResult | null) => Promise; + // Aggregate rows bind batches; only their individual provider effects emit outcomes. + aggregate?: true; + } +): Promise { + const { intent, userId } = request; + if ( + intent.accountId !== userId || + !userId || + intent.review.repository.provider === 'github' || + intent.review.authorization.kind !== 'ownerIntegration' + ) + return rejectedReviewEffect('operation_identity_mismatch'); + const owner = intent.review.authorization.owner; + if (owner.type === 'user' && owner.id !== userId) + return rejectedReviewEffect('operation_identity_mismatch'); + serializeReviewWriteRequest(intent); + const operationKey = reviewEffectOperationKey(request.operationKey, request.effect?.id); + const action = request.effect?.action ?? intent.input.action; + const orgId = owner.type === 'org' ? owner.id : null; + const resourceKey = `provider-review-operation:v1:${createHash('sha256') + .update( + JSON.stringify([ + providerReviewIntentFingerprint(intent), + request.effect?.id ?? null, + request.effect?.action ?? null, + ]) + ) + .digest('hex')}`; + if ( + handlers.execute && + ['comment', 'inlineComment', 'reply', 'submitReview'].includes(intent.input.action) + ) + await assertReviewTermsAccepted(userId); + if (!handlers.execute) { + const [existing] = await db + .select({ id: operation_ledgers.id }) + .from(operation_ledgers) + .where( + and( + eq(operation_ledgers.kilo_user_id, userId), + eq(operation_ledgers.domain, 'pr'), + eq(operation_ledgers.operation_key, operationKey) + ) + ) + .limit(1); + if (!existing) return rejectedReviewEffect('operation_not_admitted', 'same-key'); + } + let admission: Awaited>; + try { + admission = await admitOperation(db, { + userId, + orgId, + domain: 'pr', + intent: action, + operationKey, + resourceKey, + taxonomy: 'reconcile-first', + leaseSeconds: 60, + }); + } catch { + return rejectedReviewEffect('ledger_unavailable', 'same-key'); + } + const { row } = admission; + if ( + row.kilo_user_id !== userId || + row.organization_id !== orgId || + row.domain !== 'pr' || + row.operation_key !== operationKey || + row.intent !== action || + row.resource_key !== resourceKey + ) + return rejectedReviewEffect('operation_key_reuse_mismatch'); + const parsed = ReviewEffectResultSchema.safeParse(row.canonical_result?.result); + const stored = parsed.success ? parsed.data : null; + if (admission.admission === 'duplicate_settled') + return stored && + (stored.status === 'confirmed' || (stored.status === 'rejected' && stored.retry === 'never')) + ? stored + : unresolvedReviewEffect('stored_result_unavailable'); + if ( + admission.admission === 'duplicate_in_flight' || + admission.admission === 'duplicate_reconcile_in_progress' + ) + return stored?.status === 'accepted' ? stored : unresolvedReviewEffect('operation_in_progress'); + + const startedAt = Date.now(); + function event(outcome: 'completed' | 'failed' | 'ambiguous'): OutboxEventInput | null { + if (handlers.aggregate) return null; + // Keep the existing analytics catalog. Reversible actions have no corresponding legacy event. + const analyticsIntent = + action === 'merge' + ? 'merge' + : action === 'reply' + ? 'reply_comment' + : action === 'comment' || action === 'inlineComment' + ? 'create_review_comment' + : action === 'submitReview' || action === 'approve' || action === 'requestChanges' + ? 'submit_review' + : null; + return analyticsIntent === null + ? null + : { + eventName: PR_OPERATION_SETTLED_EVENT, + distinctId: request.distinctId, + properties: { + source: 'web', + surface: 'pr', + phase: 'terminal', + intent: analyticsIntent, + outcome, + duration_ms: Math.max(0, Date.now() - startedAt), + ...(admission.admission === 'admitted' + ? {} + : { + reconcile_result: + outcome === 'completed' + ? 'confirmed_completed' + : outcome === 'failed' + ? 'confirmed_absent' + : 'unresolved', + }), + }, + }; + } + let result: ReviewEffectResult; + try { + // A persisted confirmation is provider evidence even if the later settlement failed. + // Only an explicitly persisted, pre-dispatch rejection permits a same-key write retry. + if (stored?.status === 'confirmed') result = stored; + else if ( + handlers.execute && + (admission.admission === 'admitted' || + (stored?.status === 'rejected' && stored.retry === 'same-key')) + ) { + // Retire a previous safe-retry receipt before dispatch. A lost response must not leave it replayable. + const dispatching = await recordOperationProgress(db, row.id, { + result: unresolvedReviewEffect('dispatching'), + }); + if (!dispatching) throw new Error('Dispatch admission did not persist'); + result = await handlers.execute(); + } else + result = + stored?.status === 'rejected' && stored.retry === 'same-key' + ? stored + : await handlers.reconcile(stored); + result = ReviewEffectResultSchema.parse(result); + if (Buffer.byteLength(JSON.stringify({ result }), 'utf8') >= MAX_CANONICAL_RESULT_BYTES) + result = unresolvedReviewEffect('result_too_large'); + } catch { + result = unresolvedReviewEffect('provider_outcome_unknown'); + } + try { + if ( + result.status === 'confirmed' || + result.status === 'accepted' || + // An unavailable status read cannot erase durable provider acceptance. + (result.status === 'unresolved' && result.reference && stored?.status !== 'accepted') + ) { + const accepted = await recordOperationAcceptance(db, { + rowId: row.id, + providerRef: result.reference ? JSON.stringify(result.reference) : null, + canonicalResult: { result }, + }); + if (!accepted) throw new Error('Acceptance did not persist'); + } + if ( + result.status === 'confirmed' || + (result.status === 'rejected' && result.retry === 'never') + ) { + const settled = await settleOperation(db, { + rowId: row.id, + status: result.status === 'confirmed' ? 'completed' : 'failed', + outcomeCode: result.status === 'confirmed' ? 'ok' : result.code, + canonicalResult: { result }, + outboxEvent: event(result.status === 'confirmed' ? 'completed' : 'failed'), + }); + const final = ReviewEffectResultSchema.safeParse(settled.row?.canonical_result?.result); + return final.success ? final.data : unresolvedReviewEffect('ledger_settlement_unknown'); + } + if (result.status === 'rejected' && !(await recordOperationProgress(db, row.id, { result }))) + throw new Error('Pre-dispatch result did not persist'); + const pending = await markReconcilePending(db, { + rowId: row.id, + outboxEvent: result.status === 'unresolved' ? event('ambiguous') : null, + }); + if (!pending) throw new Error('Reconciliation did not persist'); + return result; + } catch { + // Even when both persistence calls fail, a takeover without evidence only reconciles. + // Never convert a possibly committed effect into a failed row or a write retry. + try { + await markReconcilePending(db, { rowId: row.id }); + } catch { + /* The admitted row still forbids replay. */ + } + return unresolvedReviewEffect( + 'ledger_persistence_failed', + 'reference' in result ? result.reference : null + ); + } +}