diff --git a/apps/web/src/lib/provider-review/bitbucket-authorization.test.ts b/apps/web/src/lib/provider-review/bitbucket-authorization.test.ts new file mode 100644 index 0000000000..20f50e51f4 --- /dev/null +++ b/apps/web/src/lib/provider-review/bitbucket-authorization.test.ts @@ -0,0 +1,245 @@ +jest.mock('@/lib/config.server', () => ({ GIT_TOKEN_SERVICE_API_URL: 'https://broker.example' })); +jest.mock('@/lib/tokens', () => ({ + generateInternalServiceToken: () => 'internal-fixture', + TOKEN_EXPIRY: { fiveMinutes: '5m' }, +})); + +import type { + OwnerIntegrationAuthorization, + RepositoryIdentity, +} from '@kilocode/app-shared/code-review/repository-identity'; +import type { BitbucketInteractiveMetadata } from '@/lib/integrations/platforms/bitbucket/interactive-client'; +import { authorizeBitbucketReview } from './bitbucket-authorization'; + +const authorization: OwnerIntegrationAuthorization = { + kind: 'ownerIntegration', + owner: { type: 'org', id: '11111111-1111-4111-8111-111111111111' }, + integrationId: '22222222-2222-4222-8222-222222222222', +}; +const repository: RepositoryIdentity = { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + workspaceUuid: '33333333-3333-4333-8333-333333333333', + repositoryId: '44444444-4444-4444-8444-444444444444', + fullName: 'team/repo', + defaultBranch: null, +}; +const providerRepository = { + uuid: `{${repository.repositoryId}}`, + full_name: repository.fullName, + workspace: { uuid: `{${repository.workspaceUuid}}`, slug: 'team' }, + mainbranch: { name: 'trunk' }, +}; +const userId = 'oauth/kilo-user'; +const input = { userId, authorization, repository }; +let metadata: BitbucketInteractiveMetadata; +let data: unknown; +let failure: string | undefined; +let calls: number; +const originalFetch = global.fetch; + +beforeEach(() => { + metadata = { + actorUserId: userId, + organizationId: authorization.owner.id, + integrationId: authorization.integrationId, + instanceUrl: 'https://bitbucket.org', + providerActor: { + credentialKind: 'bitbucketWorkspaceToken', + workspaceUuid: repository.workspaceUuid!, + workspaceSlug: 'team', + }, + grants: { scopes: ['repository', 'repository:write', 'pullrequest'] }, + }; + data = structuredClone(providerRepository); + failure = undefined; + calls = 0; + global.fetch = jest.fn(async (url, init) => { + calls++; + if ( + String(url) !== 'https://broker.example/internal/bitbucket/interactive-review' || + new Headers(init?.headers).get('authorization') !== 'Bearer internal-fixture' + ) + return Response.json({}, { status: 403 }); + const target = JSON.parse(String(init?.body)); + if (target.integrationId !== authorization.integrationId) + return Response.json({ success: false, reason: 'integration_mismatch' }); + if (target.workspaceUuid !== repository.workspaceUuid || target.workspaceSlug !== 'team') + return Response.json({ success: false, reason: 'workspace_mismatch' }); + if ( + target.repositoryUuid !== repository.repositoryId || + target.repositoryFullName !== repository.fullName + ) + return Response.json({ success: false, reason: 'repository_mismatch' }); + return Response.json( + failure + ? { success: false, reason: failure } + : { success: true, result: { status: 200, data }, metadata } + ); + }); +}); +afterEach(() => { + global.fetch = originalFetch; +}); + +it.each(['bitbucketOAuth', 'bitbucketWorkspaceToken'] as const)( + 'AC4 authorizes %s without exporting credentials or impersonating the Kilo user', + async credentialKind => { + if (credentialKind === 'bitbucketOAuth') + metadata.providerActor = { + credentialKind, + actor: { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + id: '{55555555-5555-4555-8555-555555555555}', + login: 'actual-actor', + displayName: null, + avatarUrl: null, + }, + }; + const auth = await authorizeBitbucketReview(input); + expect(auth.repository).toEqual({ ...repository, defaultBranch: 'trunk' }); + expect(auth.actor).toMatchObject( + credentialKind === 'bitbucketOAuth' + ? { id: '55555555-5555-4555-8555-555555555555', login: 'actual-actor' } + : { id: `workspace:${repository.workspaceUuid}`, login: null, displayName: 'team' } + ); + expect(auth.credentialKind).toBe(credentialKind); + expect(auth.scopes).not.toContain('pullrequest:write'); + expect(JSON.stringify(auth)).not.toContain('internal-fixture'); + expect(auth.actor.id).not.toBe(userId); + } +); +it('AC4 keeps an unavailable default branch null', async () => { + data = { ...providerRepository, mainbranch: null }; + expect((await authorizeBitbucketReview(input)).repository.defaultBranch).toBeNull(); +}); +it.each([ + { userId: '' }, + { authorization: { ...authorization, owner: { type: 'user', id: userId } } }, + { authorization: { ...authorization, integrationId: '' } }, + { repository: { ...repository, provider: 'gitlab' } }, + { repository: { ...repository, instanceUrl: 'https://other.example' } }, + { repository: { ...repository, workspaceUuid: '{33333333-3333-4333-8333-333333333333}' } }, + { repository: { ...repository, fullName: 'team/../repo' } }, + { repository: { ...repository, fullName: 'team/repo%2Fother' } }, +])('AC4 rejects invalid caller identity before accessing the broker: %j', async change => { + await expect( + authorizeBitbucketReview({ ...input, ...change } as typeof input) + ).rejects.toMatchObject({ code: 'invalid_request' }); + expect(calls).toBe(0); +}); +it.each([ + ['integrationId', '66666666-6666-4666-8666-666666666666', 'integration_mismatch'], + ['workspaceUuid', '66666666-6666-4666-8666-666666666666', 'workspace_mismatch'], + ['repositoryId', '66666666-6666-4666-8666-666666666666', 'repository_mismatch'], + ['fullName', 'team/replacement', 'repository_mismatch'], +])( + 'AC4 retains the exact %s selector instead of selecting a replacement', + async (field, value, code) => { + const selected = + field === 'integrationId' + ? { ...input, authorization: { ...authorization, integrationId: value } } + : { ...input, repository: { ...repository, [field]: value } }; + await expect(authorizeBitbucketReview(selected)).rejects.toMatchObject({ code }); + } +); +it.each([ + 'not_connected', + 'reconnect_required', + 'insufficient_permissions', + 'authentication_rejected', + 'provider_unavailable', + 'rate_limited', + 'temporarily_unavailable', +])('AC4 preserves sanitized broker failure %s without retry or data', async reason => { + failure = reason; + const error = await authorizeBitbucketReview(input).catch((error: unknown) => error); + expect(error).toMatchObject({ code: reason, message: reason }); + expect(error).not.toHaveProperty('data'); + expect(error).not.toHaveProperty('cause'); + expect(JSON.stringify(error)).not.toContain('internal-fixture'); + expect(calls).toBe(1); +}); +it.each(['actorUserId', 'organizationId', 'integrationId'] as const)( + 'AC4 rejects mismatched broker metadata %s', + async field => { + metadata[field] = 'another-identity'; + await expect(authorizeBitbucketReview(input)).rejects.toMatchObject({ + code: 'integration_mismatch', + }); + } +); +it.each(['workspaceUuid', 'workspaceSlug'] as const)( + 'AC4 rejects a workspace principal with another %s', + async field => { + metadata.providerActor = { + credentialKind: 'bitbucketWorkspaceToken', + workspaceUuid: repository.workspaceUuid!, + workspaceSlug: 'team', + [field]: field === 'workspaceUuid' ? '66666666-6666-4666-8666-666666666666' : 'other', + }; + await expect(authorizeBitbucketReview(input)).rejects.toMatchObject({ + code: 'workspace_mismatch', + }); + } +); +it.each([ + { uuid: '{66666666-6666-4666-8666-666666666666}' }, + { full_name: 'team/replacement' }, + { workspace: { uuid: '{66666666-6666-4666-8666-666666666666}', slug: 'team' } }, +])('AC4 rejects live UUID and name collisions: %j', async change => { + data = { ...providerRepository, ...change }; + await expect(authorizeBitbucketReview(input)).rejects.toMatchObject({ + code: 'workspace' in change ? 'workspace_mismatch' : 'repository_mismatch', + }); +}); +it.each(['grant', 'actor', 'revoked'] as const)( + 'AC4 rechecks %s identity on every operation', + async change => { + const auth = await authorizeBitbucketReview(input); + if (change === 'grant') metadata.grants.scopes.push('pullrequest:write'); + if (change === 'actor') + metadata.providerActor = { + credentialKind: 'bitbucketOAuth', + actor: { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + id: 'another-actor', + login: null, + displayName: null, + avatarUrl: null, + }, + }; + if (change === 'revoked') failure = 'not_connected'; + await expect( + auth.client.execute({ operation: 'repository', params: { path: auth.path } }) + ).rejects.toMatchObject({ + code: change === 'revoked' ? 'not_connected' : 'reconnect_required', + }); + } +); +it('AC4 ignores grant ordering while keeping read-only grants intact', async () => { + const auth = await authorizeBitbucketReview(input); + metadata.grants.scopes.reverse(); + await expect( + auth.client.execute({ operation: 'repository', params: { path: auth.path } }) + ).resolves.toMatchObject({ data: { full_name: 'team/repo' } }); +}); +it('AC4 rejects another organization even when the repository names match', async () => { + await expect( + authorizeBitbucketReview({ + ...input, + authorization: { + ...authorization, + owner: { type: 'org', id: '99999999-9999-4999-8999-999999999999' }, + }, + }) + ).rejects.toMatchObject({ code: 'integration_mismatch' }); +}); +it('AC4 rejects credential-bearing metadata without including the secret in its error', async () => { + Object.assign(metadata, { accessToken: 'provider-secret' }); + const error = await authorizeBitbucketReview(input).catch((error: unknown) => error); + expect(error).toMatchObject({ code: 'invalid_response' }); + expect(JSON.stringify(error)).not.toContain('provider-secret'); +}); diff --git a/apps/web/src/lib/provider-review/bitbucket-authorization.ts b/apps/web/src/lib/provider-review/bitbucket-authorization.ts new file mode 100644 index 0000000000..c122994977 --- /dev/null +++ b/apps/web/src/lib/provider-review/bitbucket-authorization.ts @@ -0,0 +1,238 @@ +import 'server-only'; + +import { z } from 'zod'; +import { + repositoryResourceKey, + type OwnerIntegrationAuthorization, + type RepositoryIdentity, +} from '@kilocode/app-shared/code-review/repository-identity'; +import type { ReviewActor, ReviewIdentity } from '@kilocode/app-shared/provider-review'; +import { + BitbucketInteractiveClientError, + createBitbucketInteractiveClient, + type BitbucketInteractiveBrokerRequest, + type BitbucketInteractiveMetadata, +} from '@/lib/integrations/platforms/bitbucket/interactive-client'; +import { + normalizeBitbucketUuid, + parseBitbucketCloneUrl, +} from '../../../../../services/git-token-service/src/bitbucket-url'; + +export function parseBitbucket( + schema: z.ZodType, + value: unknown, + code: BitbucketInteractiveClientError['code'] = 'invalid_response' +): T { + const parsed = schema.safeParse(value); + if (!parsed.success) throw new BitbucketInteractiveClientError(code); + return parsed.data; +} + +export const BitbucketUuidSchema = z.string().transform(normalizeBitbucketUuid).pipe(z.string()); +const canonicalUuid = z.string().refine(value => normalizeBitbucketUuid(value) === value); +const fullName = z + .string() + .max(511) + .refine(value => { + const parsed = parseBitbucketCloneUrl(`https://bitbucket.org/${value}.git`); + return parsed.success && parsed.fullName === value; + }); +export const BitbucketPathSchema = z + .string() + .min(1) + .max(4096) + .refine( + value => + !value.includes('\\') && + [...value].every( + character => character.charCodeAt(0) >= 32 && character.charCodeAt(0) !== 127 + ) && + value.split('/').every(part => part && part !== '.' && part !== '..') + ); +export const BitbucketRepositoryIdentitySchema = z.object({ + provider: z.literal('bitbucket'), + instanceUrl: z.literal('https://bitbucket.org'), + repositoryId: canonicalUuid, + workspaceUuid: canonicalUuid, + fullName, + defaultBranch: z.string().min(1).nullable(), +}); +export const BitbucketProviderRepositorySchema = z + .object({ + uuid: BitbucketUuidSchema, + full_name: fullName, + workspace: z.object({ uuid: BitbucketUuidSchema, slug: z.string().min(1) }), + mainbranch: z.object({ name: z.string().min(1) }).nullish(), + }) + .refine(value => value.full_name.split('/')[0] === value.workspace.slug); +export const BitbucketUserSchema = z.object({ + uuid: BitbucketUuidSchema, + nickname: z.string().nullish(), + display_name: z.string().nullish(), + links: z.object({ avatar: z.object({ href: z.string() }).optional() }).optional(), +}); +export function bitbucketActor(user: z.infer): ReviewActor { + const avatar = user.links?.avatar?.href; + return { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + id: user.uuid, + displayName: user.display_name ?? null, + login: user.nickname ?? null, + avatarUrl: avatar && z.url({ protocol: /^https$/ }).safeParse(avatar).success ? avatar : null, + }; +} +export function bitbucketRepository(value: z.infer) { + return { + provider: 'bitbucket' as const, + instanceUrl: 'https://bitbucket.org', + workspaceUuid: value.workspace.uuid, + repositoryId: value.uuid, + fullName: value.full_name, + defaultBranch: value.mainbranch?.name ?? null, + }; +} + +export function assertBitbucketRepository(expected: RepositoryIdentity, value: unknown) { + const repository = bitbucketRepository(parseBitbucket(BitbucketProviderRepositorySchema, value)); + if (expected.provider !== 'bitbucket' || repository.workspaceUuid !== expected.workspaceUuid) + throw new BitbucketInteractiveClientError('workspace_mismatch'); + if ( + repository.repositoryId !== expected.repositoryId || + repository.fullName !== expected.fullName || + repository.instanceUrl !== expected.instanceUrl + ) + throw new BitbucketInteractiveClientError('repository_mismatch'); + return repository; +} + +function principal(metadata: BitbucketInteractiveMetadata): ReviewActor { + const value = metadata.providerActor; + if (value.credentialKind === 'bitbucketOAuth') + return { ...value.actor, id: normalizeBitbucketUuid(value.actor.id) ?? value.actor.id }; + return { + provider: 'bitbucket', + instanceUrl: metadata.instanceUrl, + // Workspace principals must not collide with a provider user's UUID or impersonate the Kilo caller. + id: `workspace:${parseBitbucket(BitbucketUuidSchema, value.workspaceUuid)}`, + displayName: value.workspaceSlug, + login: null, + avatarUrl: null, + }; +} + +export async function authorizeBitbucketReview(input: { + userId: string; + authorization: OwnerIntegrationAuthorization; + repository: RepositoryIdentity; +}) { + const userId = parseBitbucket(z.string().min(1), input.userId, 'invalid_request'); + const authorization = parseBitbucket( + z.object({ + kind: z.literal('ownerIntegration'), + owner: z.object({ type: z.literal('org'), id: z.uuid() }), + integrationId: z.uuid(), + }), + input.authorization, + 'invalid_request' + ); + const expected = parseBitbucket( + BitbucketRepositoryIdentitySchema, + input.repository, + 'invalid_request' + ); + const workspaceSlug = expected.fullName.split('/')[0]; + const broker = createBitbucketInteractiveClient({ + actorUserId: userId, + organizationId: authorization.owner.id, + workspace: { + integrationId: authorization.integrationId, + workspaceUuid: expected.workspaceUuid, + workspaceSlug, + }, + repository: { repositoryUuid: expected.repositoryId, repositoryFullName: expected.fullName }, + }); + // The broker rechecks membership, blocked users, integration, cache identity and credential generation on every call. + let credentialIdentity: string | undefined; + const client = { + async execute( + request: BitbucketInteractiveBrokerRequest + ) { + const result = await broker.execute(request); + const metadata = result.metadata; + if ( + metadata.actorUserId !== userId || + metadata.organizationId !== authorization.owner.id || + metadata.integrationId !== authorization.integrationId || + metadata.instanceUrl !== expected.instanceUrl + ) + throw new BitbucketInteractiveClientError('integration_mismatch'); + const providerActor = metadata.providerActor; + if ( + providerActor.credentialKind === 'bitbucketWorkspaceToken' && + (normalizeBitbucketUuid(providerActor.workspaceUuid) !== expected.workspaceUuid || + providerActor.workspaceSlug !== workspaceSlug) + ) + throw new BitbucketInteractiveClientError('workspace_mismatch'); + const current = JSON.stringify([ + providerActor.credentialKind, + principal(metadata).id, + [...new Set(metadata.grants.scopes)].sort(), + ]); + if (credentialIdentity !== undefined && credentialIdentity !== current) + throw new BitbucketInteractiveClientError('reconnect_required'); + credentialIdentity = current; + return result; + }, + }; + const path = { + workspace: `{${expected.workspaceUuid}}`, + repo_slug: `{${expected.repositoryId}}`, + }; + const result = await client.execute({ operation: 'repository', params: { path } }); + if (result.status !== 200) throw new BitbucketInteractiveClientError('invalid_response'); + const repository = assertBitbucketRepository(expected, result.data); + return { + userId, + authorization, + repository, + path, + client, + actor: principal(result.metadata), + credentialKind: result.metadata.providerActor.credentialKind, + scopes: result.metadata.grants.scopes, + }; +} +export type BitbucketReviewAuthorization = Awaited>; + +export function assertBitbucketReviewIdentity( + auth: BitbucketReviewAuthorization, + identity: ReviewIdentity +) { + const repository = parseBitbucket( + BitbucketRepositoryIdentitySchema, + identity.repository, + 'invalid_request' + ); + const authorization = parseBitbucket( + z.object({ + kind: z.literal('ownerIntegration'), + owner: z.object({ type: z.literal('org'), id: z.uuid() }), + integrationId: z.uuid(), + }), + identity.authorization, + 'invalid_request' + ); + if ( + repositoryResourceKey(auth.userId, { repository, authorization }) !== + repositoryResourceKey(auth.userId, auth) + ) + throw new BitbucketInteractiveClientError('repository_mismatch'); + const number = parseBitbucket(z.string().regex(/^[1-9]\d*$/), identity.number, 'invalid_request'); + if ( + identity.reviewId !== number || + identity.canonicalUrl !== + `https://bitbucket.org/${auth.repository.fullName}/pull-requests/${number}` + ) + throw new BitbucketInteractiveClientError('repository_mismatch'); +} diff --git a/apps/web/src/lib/provider-review/bitbucket-read.test.ts b/apps/web/src/lib/provider-review/bitbucket-read.test.ts new file mode 100644 index 0000000000..1a7082affd --- /dev/null +++ b/apps/web/src/lib/provider-review/bitbucket-read.test.ts @@ -0,0 +1,1609 @@ +jest.mock('@/lib/config.server', () => ({ GIT_TOKEN_SERVICE_API_URL: 'https://broker.example' })); +jest.mock('@/lib/tokens', () => ({ + generateInternalServiceToken: () => 'internal-fixture', + TOKEN_EXPIRY: { fiveMinutes: '5m' }, +})); + +import type { + OwnerIntegrationAuthorization, + RepositoryIdentity, +} from '@kilocode/app-shared/code-review/repository-identity'; +import { + reviewActionAvailability, + type ReviewCursor, + type ReviewIdentity, + type ReviewRevision, +} from '@kilocode/app-shared/provider-review'; +import type { + BitbucketInteractiveBrokerRequest, + BitbucketInteractiveMetadata, +} from '@/lib/integrations/platforms/bitbucket/interactive-client'; +import { + createBitbucketInteractiveApi, + BitbucketInteractiveBrokerRequestSchema, + type BitbucketInteractiveRequest, +} from '../../../../../services/git-token-service/src/bitbucket-interactive-api'; +import { authorizeBitbucketReview } from './bitbucket-authorization'; +import { + getBitbucketChecks, + getBitbucketFileContext, + getBitbucketReview, + listBitbucketDiscussions, + listBitbucketFiles, + listBitbucketInbox, +} from './bitbucket-read'; + +const userId = 'oauth/kilo-user'; +const authorization: OwnerIntegrationAuthorization = { + kind: 'ownerIntegration', + owner: { type: 'org', id: '11111111-1111-4111-8111-111111111111' }, + integrationId: '22222222-2222-4222-8222-222222222222', +}; +const repository: RepositoryIdentity & { provider: 'bitbucket' } = { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + workspaceUuid: '33333333-3333-4333-8333-333333333333', + repositoryId: '44444444-4444-4444-8444-444444444444', + fullName: 'team/repo', + defaultBranch: 'trunk', +}; +const actor = { + uuid: '{55555555-5555-4555-8555-555555555555}', + nickname: 'provider-actor', + display_name: 'Provider Actor', +}; +const destination = { + uuid: `{${repository.repositoryId}}`, + full_name: repository.fullName, + workspace: { uuid: `{${repository.workspaceUuid}}`, slug: 'team' }, + mainbranch: { name: 'trunk' }, +}; +const source = { + uuid: '{66666666-6666-4666-8666-666666666666}', + full_name: 'fork/repo', + workspace: { uuid: '{77777777-7777-4777-8777-777777777777}', slug: 'fork' }, +}; +const revision: ReviewRevision = { + headSha: 'a'.repeat(40), + targetHeadSha: 'c'.repeat(40), + baseSha: null, + startSha: null, +}; +const fileRevision = { ...revision, baseSha: 'b'.repeat(40) }; +const identity: ReviewIdentity = { + repository, + authorization, + number: '7', + reviewId: '7', + canonicalUrl: 'https://bitbucket.org/team/repo/pull-requests/7', +}; +const providerReview = { + type: 'pullrequest', + id: 7, + title: 'Fork review', + description: 'Review details', + state: 'OPEN', + draft: false, + updated_on: '2026-08-30T00:00:00Z', + author: { ...actor, uuid: '{88888888-8888-4888-8888-888888888888}' }, + links: { html: { href: identity.canonicalUrl } }, + source: { repository: source, branch: { name: 'feature' }, commit: { hash: revision.headSha } }, + destination: { + repository: destination, + branch: { name: 'release/stable' }, + commit: { hash: revision.targetHeadSha }, + }, + participants: [ + { + user: actor, + role: 'REVIEWER', + state: 'approved', + approved: true, + participated_on: '2026-08-30T00:00:00Z', + }, + ], +}; +const apiRoot = `https://api.bitbucket.org/2.0/repositories/${encodeURIComponent(destination.workspace.uuid)}/${encodeURIComponent(destination.uuid)}`; +const sourceRoot = `https://api.bitbucket.org/2.0/repositories/${encodeURIComponent(source.workspace.uuid)}/${encodeURIComponent(source.uuid)}`; +const stat = { + status: 'renamed', + lines_added: 2, + lines_removed: 1, + old: { + path: 'src/old.ts', + links: { self: { href: `${apiRoot}/src/${fileRevision.baseSha}/src/old.ts` } }, + }, + new: { + path: 'src/new.ts', + links: { self: { href: `${sourceRoot}/src/${revision.headSha}/src/new.ts` } }, + }, +}; +const patch = + 'diff --git a/src/old.ts b/src/new.ts\n--- a/src/old.ts\n+++ b/src/new.ts\n@@ -1 +1,2 @@\n-old\n+first\n+second\n'; +const comment = { + id: 1, + created_on: '2026-08-30T00:00:00Z', + content: { raw: 'Review comment' }, + user: actor, + inline: { path: 'src/new.ts', to: 2, start_to: 1 }, + resolution: { type: 'comment_resolution' }, + pullrequest: { id: 7 }, +}; +const context = { + file: { oldPath: 'src/old.ts', newPath: 'src/new.ts', revision: fileRevision }, + side: 'new' as const, + startLine: 2, + lineCount: 2, +}; +let metadata: BitbucketInteractiveMetadata; +let review: typeof providerReview; +let rows: Map; +let nextRows: Map; +let failures: Map; +let nextLinks: Map; +let oversized: Set; +let afterResponse: ((operation: string) => void) | undefined; +let sourceText: string; +let metadataOverride: object; +const originalFetch = global.fetch; + +beforeEach(() => { + review = structuredClone(providerReview); + metadata = { + actorUserId: userId, + organizationId: authorization.owner.id, + integrationId: authorization.integrationId, + instanceUrl: 'https://bitbucket.org', + providerActor: { + credentialKind: 'bitbucketWorkspaceToken', + workspaceUuid: repository.workspaceUuid, + workspaceSlug: 'team', + }, + grants: { scopes: ['repository', 'repository:write', 'pullrequest', 'pullrequest:write'] }, + }; + rows = new Map([ + ['repository', destination], + ['pullRequests', [review]], + ['diffstat', [structuredClone(stat)]], + ['diff', patch], + ['commits', [{ hash: revision.headSha }]], + [`commit:${revision.headSha.slice(0, 12)}`, { hash: revision.headSha }], + [`commit:${revision.targetHeadSha!.slice(0, 12)}`, { hash: revision.targetHeadSha }], + ['statuses', []], + ['restrictions', []], + [ + 'branch', + { + name: 'release/stable', + merge_strategies: [ + 'merge_commit', + 'squash', + 'fast_forward', + 'squash_fast_forward', + 'rebase_fast_forward', + 'rebase_merge', + ], + }, + ], + [ + 'comments', + [ + comment, + { + id: 2, + created_on: comment.created_on, + content: { raw: 'Reply' }, + parent: { id: 1 }, + user: null, + }, + ], + ], + ]); + nextRows = new Map(); + failures = new Map(); + nextLinks = new Map(); + oversized = new Set(); + afterResponse = undefined; + sourceText = 'first\nsecond\nthird\n'; + metadataOverride = {}; + global.fetch = jest.fn(async (endpoint, init) => { + if ( + String(endpoint) !== 'https://broker.example/internal/bitbucket/interactive-review' || + new Headers(init?.headers).get('authorization') !== 'Bearer internal-fixture' + ) + return Response.json({}, { status: 403 }); + const target = JSON.parse(String(init?.body)); + if ( + target.integrationId !== authorization.integrationId || + target.workspaceUuid !== repository.workspaceUuid || + target.repositoryUuid !== repository.repositoryId || + target.repositoryFullName !== repository.fullName + ) + return Response.json({ success: false, reason: 'repository_mismatch' }); + if (!BitbucketInteractiveBrokerRequestSchema.safeParse(target.request).success) + return Response.json({ success: false, reason: 'invalid_request' }); + const request: BitbucketInteractiveBrokerRequest = target.request; + if ( + request.params.path.workspace !== destination.workspace.uuid || + request.params.path.repo_slug !== destination.uuid + ) + return Response.json({ success: false, reason: 'repository_mismatch' }); + const { source: selector, ...native } = request; + let workspace = destination.workspace.uuid, + repo = destination.uuid; + if (selector) { + if ( + !['file', 'fileMetadata'].includes(request.operation) || + selector.pullRequestId !== review.id || + `{${selector.workspaceUuid}}` !== review.source.repository.workspace.uuid || + `{${selector.repositoryUuid}}` !== review.source.repository.uuid + ) + return Response.json({ success: false, reason: 'repository_mismatch' }); + if ( + !('commit' in request.params.path) || + request.params.path.commit !== revision.headSha || + !revision.headSha.startsWith(review.source.commit.hash) + ) + return Response.json({ success: false, reason: 'conflict' }); + workspace = source.workspace.uuid; + repo = source.uuid; + } + const api = createBitbucketInteractiveApi({ + scope: { kind: 'repository', workspace, repository: repo }, + accessToken: 'provider-secret', + fetch: async (url, requestInit) => { + if (requestInit?.method !== 'GET') throw new Error('Read adapter attempted a write'); + const parsed = new URL(String(url)); + const operation = request.operation; + const resourcePath = decodeURIComponent(parsed.pathname); + if ( + (operation === 'diff' || operation === 'diffstat') && + (!resourcePath.endsWith(`/${operation}/${revision.headSha}..${revision.targetHeadSha}`) || + parsed.searchParams.get('topic') !== 'true') + ) + return Response.json({}, { status: 400 }); + if ( + operation === 'branch' && + !resourcePath.endsWith(`/refs/branches/${review.destination.branch.name}`) + ) + return Response.json({}, { status: 404 }); + if ( + ['statuses', 'comments', 'commits'].includes(operation) && + !resourcePath.endsWith(`/pullrequests/${review.id}/${operation}`) + ) + return Response.json({}, { status: 404 }); + const page = parsed.searchParams.get('page') ?? '1'; + const failure = failures.get(`${operation}:${page}`) ?? failures.get(operation); + if (failure) + return Response.json({ error: { message: 'provider-secret' } }, { status: failure }); + if (oversized.has(operation)) + return new Response('partial', { + headers: { 'content-type': 'text/plain', 'content-length': '1000001' }, + }); + let data = + operation === 'pullRequest' + ? review + : operation === 'commit' + ? rows.get(`commit:${resourcePath.split('/').at(-1)}`) + : rows.get(operation); + if (operation === 'commit' && data === undefined) return Response.json({}, { status: 404 }); + if (operation === 'file' || operation === 'fileMetadata') { + const path = decodeURIComponent(parsed.pathname); + const newSide = + path.includes(source.uuid) && path.includes(`/src/${revision.headSha}/src/new.ts`); + const oldSide = + path.includes(destination.uuid) && + path.includes(`/src/${fileRevision.baseSha}/src/old.ts`); + if (!newSide && !oldSide) return Response.json({}, { status: 404 }); + const text = newSide ? sourceText : 'base\ncontext\n'; + data = + operation === 'file' + ? (rows.get('file') ?? text) + : { + type: 'commit_file', + path: newSide ? 'src/new.ts' : 'src/old.ts', + commit: { hash: newSide ? revision.headSha : fileRevision.baseSha }, + attributes: [], + size: Buffer.byteLength(text), + ...metadataOverride, + }; + } + if (Array.isArray(data)) { + let values = page === '2' ? (nextRows.get(operation) ?? data) : data; + if (operation === 'diffstat' && parsed.searchParams.has('path')) + values = values.filter( + value => + value.old?.path === parsed.searchParams.get('path') || + value.new?.path === parsed.searchParams.get('path') + ); + const next = + nextLinks.get(operation) ?? + (nextRows.has(operation) && page === '1' + ? (() => { + parsed.searchParams.set('page', '2'); + return parsed.href; + })() + : undefined); + data = { values, ...(next ? { next } : {}) }; + } + const response = + data instanceof Uint8Array + ? new Response(data, { headers: { 'content-type': 'text/plain' } }) + : typeof data === 'string' + ? new Response(data, { headers: { 'content-type': 'text/plain' } }) + : Response.json(data); + afterResponse?.(operation); + return response; + }, + }); + try { + const result = await api.execute({ + ...native, + params: { ...native.params, path: { ...native.params.path, workspace, repo_slug: repo } }, + } as BitbucketInteractiveRequest); + return Response.json({ success: true, result, metadata }); + } catch (error) { + return Response.json({ success: false, reason: (error as { code: string }).code }); + } + }); +}); +afterEach(() => { + global.fetch = originalFetch; +}); +const auth = () => authorizeBitbucketReview({ userId, authorization, repository }); +function oauth() { + metadata.providerActor = { + credentialKind: 'bitbucketOAuth', + actor: { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + id: actor.uuid, + displayName: actor.display_name, + login: actor.nickname, + avatarUrl: null, + }, + }; +} + +it.each(['workspace', 'oauth'])( + 'AC4 labels a repository inbox with its actual %s principal', + async kind => { + if (kind === 'oauth') oauth(); + const result = await listBitbucketInbox(await auth()); + expect(result).toMatchObject({ + items: [{ identity, title: 'Fork review' }], + scope: { + kind: 'repository', + repository, + actor: + kind === 'oauth' + ? { login: 'provider-actor' } + : { id: `workspace:${repository.workspaceUuid}`, login: null }, + }, + }); + expect(JSON.stringify(result)).not.toContain('provider-secret'); + } +); +it('AC4 reads condensed inbox destinations without inventing a workspace', async () => { + rows.set('pullRequests', [ + { + ...providerReview, + destination: { repository: { uuid: destination.uuid, full_name: destination.full_name } }, + }, + ]); + expect((await listBitbucketInbox(await auth())).items[0].identity.repository).toEqual(repository); +}); +it('AC4 normalizes the overview, fork identity, native participant state and all destination strategies', async () => { + const result = await getBitbucketReview(await auth(), '7'); + expect(result).toMatchObject({ + identity, + title: 'Fork review', + bodyMarkdown: 'Review details', + revision, + source: { + repository: { + repositoryId: source.uuid.slice(1, -1), + workspaceUuid: source.workspace.uuid.slice(1, -1), + fullName: 'fork/repo', + defaultBranch: null, + }, + }, + target: { branch: 'release/stable' }, + counts: { commits: 1, files: 1, additions: 2, deletions: 1 }, + checks: { status: 'none', checks: [] }, + providerState: { + provider: 'bitbucket', + expectedHeadProtection: 'none', + participants: [ + { actor: { id: actor.uuid.slice(1, -1) }, state: 'approved', role: 'REVIEWER' }, + ], + }, + }); + expect(result.merge.methods.map(value => value.id)).toEqual([ + 'merge_commit', + 'squash', + 'fast_forward', + 'squash_fast_forward', + 'rebase_fast_forward', + 'rebase_merge', + ]); + expect(JSON.stringify(result)).not.toContain('provider-secret'); +}); +it.each(['MERGED', 'DECLINED', 'SUPERSEDED'])('AC4 keeps %s reviews readable', async state => { + review.state = state; + const result = await getBitbucketReview(await auth(), '7'); + expect(result.state).toBe(state === 'MERGED' ? 'merged' : 'closed'); + expect(result.counts.files).toBe(1); + expect(result.authorization.capabilities.merge.restrictions).toContain('review_closed'); +}); +it.each([ + ['workspace', ['repository', 'pullrequest']], + ['oauth', ['repository', 'pullrequest']], + ['workspace', ['repository', 'repository:write', 'pullrequest']], + ['oauth', ['repository', 'repository:write', 'pullrequest']], +] as const)( + 'AC4 keeps old %s read grants %j and explains each missing write grant', + async (kind, scopes) => { + if (kind === 'oauth') oauth(); + metadata.grants.scopes = [...scopes]; + const result = await getBitbucketReview(await auth(), '7'); + expect(result.title).toBe('Fork review'); + for (const action of [ + 'read', + 'comment', + 'inlineComment', + 'reply', + 'resolveThread', + 'reopenThread', + ] as const) + expect(reviewActionAvailability(result.authorization.capabilities[action])).toBe('available'); + for (const action of [ + 'approve', + 'unapprove', + 'requestChanges', + 'removeChangeRequest', + 'submitReview', + 'merge', + ] as const) + expect(result.authorization.capabilities[action]).toMatchObject({ + support: 'supported', + permission: 'forbidden', + explanation: 'missing_scope:pullrequest:write', + recovery: kind === 'oauth' ? 'reconnect' : 'replaceToken', + }); + } +); +it.each(['workspace', 'oauth'])( + 'AC6 uses implied %s pullrequest permission for comment capabilities', + async kind => { + if (kind === 'oauth') oauth(); + metadata.grants.scopes = ['pullrequest:write']; + const { capabilities } = (await getBitbucketReview(await auth(), '7')).authorization; + for (const action of [ + 'read', + 'comment', + 'inlineComment', + 'reply', + 'resolveThread', + 'reopenThread', + ] as const) { + expect(reviewActionAvailability(capabilities[action])).toBe('available'); + expect(capabilities[action]).toMatchObject({ explanation: '', recovery: 'none' }); + } + expect(capabilities.deleteBranch).toMatchObject({ + permission: 'forbidden', + explanation: 'missing_scope:repository:write', + recovery: kind === 'oauth' ? 'reconnect' : 'replaceToken', + }); + } +); +it.each([ + ['workspace', false, 'available'], + ['oauth', false, 'available'], + ['workspace', true, 'restricted'], + ['oauth', true, 'restricted'], +] as const)( + 'AC6 uses implied %s discussion permission with deleted=%s as %s', + async (kind, deleted, availability) => { + if (kind === 'oauth') oauth(); + metadata.grants.scopes = ['pullrequest:write']; + rows.set('comments', [{ ...comment, deleted }]); + const result = await listBitbucketDiscussions(await auth(), identity); + for (const action of ['resolveThread', 'reopenThread'] as const) { + const capability = result.items[0].capabilities[action]!; + expect(reviewActionAvailability(capability)).toBe(availability); + expect(capability).toMatchObject({ + permission: 'allowed', + restrictions: deleted ? ['comment_deleted'] : [], + recovery: 'none', + }); + } + } +); +it.each([ + ['workspace', []], + ['oauth', []], + ['workspace', ['repository:write']], + ['oauth', ['repository:write']], +] as const)( + 'AC6 denies missing %s review grants with raw scopes %j and retains recovery', + async (kind, scopes) => { + if (kind === 'oauth') oauth(); + metadata.grants.scopes = [...scopes]; + const selected = await auth(); + const { capabilities } = (await getBitbucketReview(selected, '7')).authorization; + expect(reviewActionAvailability(capabilities.read)).toBe('available'); + for (const action of [ + 'comment', + 'inlineComment', + 'reply', + 'resolveThread', + 'reopenThread', + 'approve', + 'unapprove', + 'requestChanges', + 'removeChangeRequest', + 'submitReview', + 'merge', + ] as const) { + expect(reviewActionAvailability(capabilities[action])).toBe('forbidden'); + expect(capabilities[action]).toMatchObject({ + permission: 'forbidden', + recovery: kind === 'oauth' ? 'reconnect' : 'replaceToken', + }); + } + expect(capabilities.comment.explanation).toBe('missing_scope:pullrequest'); + expect(capabilities.merge.explanation).toBe('missing_scope:pullrequest:write'); + if (scopes.length === 0) + expect(capabilities.deleteBranch).toMatchObject({ + permission: 'forbidden', + explanation: 'missing_scope:repository:write', + recovery: kind === 'oauth' ? 'reconnect' : 'replaceToken', + }); + const discussions = await listBitbucketDiscussions(selected, identity); + for (const action of ['resolveThread', 'reopenThread'] as const) + expect(discussions.items[0].capabilities[action]).toMatchObject({ + permission: 'forbidden', + explanation: 'missing_scope:pullrequest', + recovery: kind === 'oauth' ? 'reconnect' : 'replaceToken', + }); + } +); +it('AC6 supports approvals, withdrawal, change requests and resolution when grants permit', async () => { + oauth(); + metadata.grants.scopes = ['pullrequest:write']; + let result = await getBitbucketReview(await auth(), '7'); + for (const action of [ + 'approve', + 'unapprove', + 'requestChanges', + 'resolveThread', + 'reopenThread', + ] as const) + expect(reviewActionAvailability(result.authorization.capabilities[action])).toBe('available'); + review.participants[0].state = 'changes_requested'; + review.participants[0].approved = false; + result = await getBitbucketReview(await auth(), '7'); + expect(reviewActionAvailability(result.authorization.capabilities.removeChangeRequest)).toBe( + 'available' + ); + expect(result.providerState).toMatchObject({ participants: [{ state: 'changes_requested' }] }); + expect(result.authorization.capabilities.unapprove.restrictions).toContain('not_approved'); + expect(result.authorization.capabilities.merge).toMatchObject({ + permission: 'unknown', + explanation: 'repository_merge_permission_unknown', + }); +}); +it('AC6 never derives atomic guards or API support from a successful read', async () => { + metadata.grants.scopes = ['pullrequest:write']; + const result = await getBitbucketReview(await auth(), '7'); + for (const [action, issue] of [ + ['enableAutoMerge', '22062'], + ['disableAutoMerge', '22062'], + ['updateBranch', '20489'], + ['addReaction', '21346'], + ['removeReaction', '21346'], + ] as const) { + const capability = result.authorization.capabilities[action]; + expect(reviewActionAvailability(capability)).toBe('unsupported'); + expect(capability.evidenceUrl).toBe(`https://jira.atlassian.com/browse/BCLOUD-${issue}`); + expect(capability.explanation).not.toBe(''); + } + expect(result.authorization.capabilities.merge.expectedHeadProtection).toBe('none'); + expect(result.authorization.capabilities.inlineComment.expectedHeadProtection).toBe('none'); + expect(result.authorization.capabilities.deleteBranch.restrictions).toContain( + 'fork_source_requires_separate_authorization' + ); +}); +it.each([{ strategies: [] }, { strategies: ['rebase_merge', 'future_strategy'] }])( + 'AC4 retains the destination strategy set $strategies', + async ({ strategies }) => { + rows.set('branch', { name: 'release/stable', merge_strategies: strategies }); + const result = await getBitbucketReview(await auth(), '7'); + expect(result.merge.methods.map(value => value.id)).toEqual(strategies); + expect( + result.authorization.capabilities.merge.restrictions.includes('merge_strategies_unavailable') + ).toBe(strategies.length === 0); + } +); +it.each([false, true])('AC4 separates advisory and enforced checks: %s', async enforced => { + rows.set('restrictions', [ + { kind: 'require_passing_builds_to_merge', pattern: 'release/*', value: 1 }, + { kind: 'require_approvals_to_merge', pattern: 'main', value: 100 }, + ...(enforced ? [{ kind: 'enforce_merge_checks', pattern: 'release/*' }] : []), + ]); + const result = await getBitbucketReview(await auth(), '7'); + expect( + result.authorization.capabilities.merge.restrictions.includes('passing_builds_required') + ).toBe(enforced); + expect(result.authorization.capabilities.merge.restrictions).not.toContain('approvals_required'); + expect(result.authorization.capabilities.merge.explanation).toContain( + enforced ? 'enforced_merge_checks' : 'advisory_merge_checks' + ); + expect(result.checks).toEqual({ status: 'none', checks: [] }); +}); +it('AC4 clears a satisfied enforced build policy without inventing per-check requirements', async () => { + rows.set('restrictions', [ + { kind: 'enforce_merge_checks', pattern: '*' }, + { kind: 'require_passing_builds_to_merge', pattern: '*', value: 1 }, + ]); + rows.set('statuses', [ + { key: 'build', name: 'Build', state: 'SUCCESSFUL', url: 'https://ci.example/build' }, + ]); + const result = await getBitbucketReview(await auth(), '7'); + expect(result.authorization.capabilities.merge.restrictions).toEqual([]); + expect(result.checks).toMatchObject({ + status: 'reported', + checks: [{ id: 'build', state: 'passed', required: null }], + }); +}); +it('AC4 separates actor restrictions, branching-model uncertainty and unmet approval policy', async () => { + rows.set('restrictions', [ + { kind: 'restrict_merges', pattern: '*', users: [], groups: [] }, + { kind: 'require_no_changes_requested', branch_match_kind: 'branching_model', pattern: '' }, + { kind: 'enforce_merge_checks', pattern: '*' }, + { kind: 'require_approvals_to_merge', pattern: '*', value: 2 }, + ]); + const result = await getBitbucketReview(await auth(), '7'); + expect(result.authorization.capabilities.merge.restrictions).toEqual( + expect.arrayContaining([ + 'actor_merge_restricted', + 'branching_model_restrictions_unknown', + 'approvals_required', + ]) + ); +}); +it.each(['branch', 'restrictions'])( + 'AC4 keeps inaccessible %s policy distinct from supported merge', + async operation => { + failures.set(operation, 403); + const result = await getBitbucketReview(await auth(), '7'); + expect(result.title).toBe('Fork review'); + expect(result.authorization.capabilities.merge.restrictions).toContain( + operation === 'branch' ? 'merge_strategies_unavailable' : 'merge_restrictions_unavailable' + ); + } +); +it.each([ + ['INPROGRESS', 'running'], + ['FAILED', 'failed'], + ['STOPPED', 'cancelled'], + ['UNKNOWN', 'unknown'], +])('AC4 maps check state %s honestly', async (state, expected) => { + rows.set('statuses', [{ key: 'build', state, url: 'javascript:unsafe' }]); + expect(await getBitbucketChecks(await auth(), identity, revision)).toMatchObject({ + status: 'reported', + checks: [{ state: expected, detailsUrl: null, required: null }], + }); +}); +it.each([ + [403, 'unavailable'], + [503, 'provider_unavailable'], +] as const)('AC4 distinguishes check failure %s from no checks', async (status, expected) => { + failures.set('statuses', status); + if (status === 403) + expect(await getBitbucketChecks(await auth(), identity)).toEqual({ + status: 'unavailable', + explanation: 'insufficient_permissions', + }); + else + await expect(getBitbucketChecks(await auth(), identity)).rejects.toMatchObject({ + code: expected, + }); +}); +it('AC4–AC6 keeps empty inbox, files, checks and discussion separate', async () => { + for (const key of ['pullRequests', 'diffstat', 'comments']) rows.set(key, []); + const selected = await auth(); + expect(await listBitbucketInbox(selected)).toMatchObject({ + items: [], + nextCursor: null, + scope: { kind: 'repository' }, + }); + expect(await listBitbucketFiles(selected, identity, revision)).toEqual({ + items: [], + nextCursor: null, + }); + expect(await listBitbucketDiscussions(selected, identity)).toEqual({ + items: [], + nextCursor: null, + }); + expect(await getBitbucketChecks(selected, identity)).toEqual({ status: 'none', checks: [] }); + expect((await getBitbucketReview(selected, '7')).counts).toEqual({ + files: 0, + commits: 1, + additions: 0, + deletions: 0, + }); +}); +it('AC5 retains both rename paths, the merge-base entry, patch and exact provider link', async () => { + expect((await listBitbucketFiles(await auth(), identity, revision)).items).toMatchObject([ + { + oldPath: 'src/old.ts', + newPath: 'src/new.ts', + status: 'renamed', + revision: fileRevision, + patch, + additions: 2, + deletions: 1, + canonicalUrl: `https://bitbucket.org/fork/repo/src/${revision.headSha}/src/new.ts`, + }, + ]); +}); +it.each([ + ['added', null, stat.new, null], + ['removed', stat.old, null, fileRevision.baseSha], +] as const)('AC5 retains absent sides for %s files', async (status, old, next, baseSha) => { + rows.set('diffstat', [{ ...stat, status, old, new: next }]); + const result = (await listBitbucketFiles(await auth(), identity, revision)).items[0]; + expect(result).toMatchObject({ + oldPath: old?.path ?? null, + newPath: next?.path ?? null, + status: status === 'removed' ? 'deleted' : 'added', + revision: { ...revision, baseSha }, + }); +}); +it('AC5 preserves unknown line counts beside confirmed numeric counts', async () => { + rows.set('diffstat', [{ ...stat, lines_added: null, lines_removed: undefined }]); + const selected = await auth(); + expect((await listBitbucketFiles(selected, identity, revision)).items[0]).toMatchObject({ + additions: null, + deletions: null, + patch, + }); + expect((await getBitbucketReview(selected, '7')).counts).toMatchObject({ + files: 1, + additions: null, + deletions: null, + }); +}); +it.each(['binary', 'truncated', 'unavailable'] as const)( + 'AC5 explains %s patches without false empty files', + async content => { + if (content === 'binary') + rows.set('diff', 'Binary files a/src/old.ts and b/src/new.ts differ\n'); + if (content === 'truncated') rows.set('diff', '@@ -1 +1,2 @@\n-old\n+first\n'); + if (content === 'unavailable') { + rows.set('diff', ''); + rows.set('diffstat', [{ ...stat, lines_added: null, lines_removed: null }]); + } + const result = (await listBitbucketFiles(await auth(), identity, revision)).items[0]; + expect(result.content).toBe(content); + expect(result.canonicalUrl).toContain(`/src/${revision.headSha}/src/new.ts`); + if (content !== 'unavailable') expect(result.patch).toBeNull(); + } +); +it('AC5 turns bounded diff failure into truncated metadata, not a lost file', async () => { + oversized.add('diff'); + expect((await listBitbucketFiles(await auth(), identity, revision)).items[0]).toMatchObject({ + content: 'truncated', + patch: null, + additions: 2, + deletions: 1, + }); +}); +it('AC5 reads fork source context and old merge-base context through destination authorization', async () => { + const selected = await auth(); + expect(await getBitbucketFileContext(selected, identity, context)).toMatchObject({ + revision: fileRevision, + content: 'available', + path: 'src/new.ts', + side: 'new', + lines: ['second', 'third'], + totalLines: 3, + }); + expect( + await getBitbucketFileContext(selected, identity, { ...context, side: 'old', startLine: 1 }) + ).toMatchObject({ + revision: fileRevision, + content: 'available', + path: 'src/old.ts', + side: 'old', + lines: ['base', 'context'], + canonicalUrl: `https://bitbucket.org/team/repo/src/${fileRevision.baseSha}/src/old.ts`, + }); +}); +it('AC5 resolves abbreviated review hashes before immutable source reads', async () => { + review.source.commit.hash = revision.headSha.slice(0, 12); + review.destination.commit.hash = revision.targetHeadSha!.slice(0, 12); + const selected = await auth(); + expect((await getBitbucketReview(selected, '7')).revision).toEqual(revision); + expect((await getBitbucketFileContext(selected, identity, context)).lines).toEqual([ + 'second', + 'third', + ]); +}); +it.each(['headSha', 'targetHeadSha', 'baseSha'] as const)( + 'AC5 rejects stale %s without retargeting context', + async field => { + await expect( + getBitbucketFileContext(await auth(), identity, { + ...context, + file: { ...context.file, revision: { ...fileRevision, [field]: 'd'.repeat(40) } }, + }) + ).rejects.toMatchObject({ code: 'conflict' }); + } +); +it.each(['diff', 'file', 'statuses'] as const)( + 'AC4–AC5 rejects head drift during %s reads', + async operation => { + afterResponse = current => { + if (current === operation) review.source.commit.hash = 'd'.repeat(40); + }; + const selected = await auth(); + const result = + operation === 'diff' + ? listBitbucketFiles(selected, identity, revision) + : operation === 'file' + ? getBitbucketFileContext(selected, identity, context) + : getBitbucketChecks(selected, identity); + await expect(result).rejects.toMatchObject({ code: 'conflict' }); + } +); +it.each([ + [{ attributes: ['binary'] }, 'binary'], + [{ size: 1000001 }, 'truncated'], + [{ size: 100 }, 'truncated'], + [{ type: 'commit_directory' }, 'unavailable'], + [{ attributes: ['link'] }, 'unavailable'], +] as const)('AC5 explains unavailable context metadata %j', async (change, content) => { + metadataOverride = change; + expect(await getBitbucketFileContext(await auth(), identity, context)).toMatchObject({ + content, + lines: [], + totalLines: null, + canonicalUrl: `https://bitbucket.org/fork/repo/src/${revision.headSha}/src/new.ts`, + }); +}); +it('AC5 preserves an empty source file as available', async () => { + sourceText = ''; + expect(await getBitbucketFileContext(await auth(), identity, context)).toMatchObject({ + content: 'available', + lines: [], + totalLines: 0, + }); +}); +it.each([{ path: 'wrong.ts' }, { commit: { hash: 'd'.repeat(40) } }])( + 'AC5 rejects mismatched context metadata %j', + async change => { + metadataOverride = change; + await expect(getBitbucketFileContext(await auth(), identity, context)).rejects.toMatchObject({ + code: 'conflict', + }); + } +); +it.each([0, 501])('AC5 rejects invalid context length %s', async lineCount => { + await expect( + getBitbucketFileContext(await auth(), identity, { ...context, lineCount }) + ).rejects.toMatchObject({ code: 'invalid_request' }); +}); +it.each([403, 404, 503])( + 'AC5 distinguishes context denial and retryable failure %s', + async status => { + failures.set('fileMetadata', status); + const selected = await auth(); + if (status !== 503) + expect(await getBitbucketFileContext(selected, identity, context)).toMatchObject({ + content: 'unavailable', + lines: [], + totalLines: null, + }); + else { + await expect(getBitbucketFileContext(selected, identity, context)).rejects.toMatchObject({ + code: 'provider_unavailable', + }); + failures.clear(); + expect((await getBitbucketFileContext(selected, identity, context)).lines).toEqual([ + 'second', + 'third', + ]); + } + } +); +it('AC5 never replaces a missing old revision with the destination head', async () => { + rows.set('diffstat', [{ ...stat, old: { path: 'src/old.ts' } }]); + expect( + await getBitbucketFileContext(await auth(), identity, { + ...context, + side: 'old', + file: { ...context.file, revision }, + }) + ).toMatchObject({ + content: 'unavailable', + totalLines: null, + canonicalUrl: `${identity.canonicalUrl}/diff`, + }); +}); +it.each(['inbox', 'files', 'discussions'] as const)( + 'AC4–AC6 retains a loaded %s page across failure and retry', + async surface => { + const operation = + surface === 'inbox' ? 'pullRequests' : surface === 'files' ? 'diffstat' : 'comments'; + if (surface === 'inbox') + nextRows.set(operation, [ + { + ...providerReview, + id: 8, + title: 'Later review', + links: { html: { href: identity.canonicalUrl.replace('/7', '/8') } }, + }, + ]); + if (surface === 'files') + nextRows.set(operation, [{ ...stat, old: null, new: { path: 'later.ts' } }]); + if (surface === 'discussions') { + rows.set( + operation, + Array.from({ length: 25 }, (_, index) => ({ ...comment, id: index + 1 })) + ); + nextRows.set(operation, [{ ...comment, id: 26 }]); + } + const selected = await auth(); + const read = (cursor?: ReviewCursor | null) => + surface === 'inbox' + ? listBitbucketInbox(selected, { cursor }) + : surface === 'files' + ? listBitbucketFiles(selected, identity, revision, cursor) + : listBitbucketDiscussions(selected, identity, cursor); + const first = await read(); + const retained = JSON.stringify(first); + expect(first.nextCursor).not.toBeNull(); + failures.set(`${operation}:2`, 503); + await expect(read(first.nextCursor)).rejects.toMatchObject({ code: 'provider_unavailable' }); + expect(JSON.stringify(first)).toBe(retained); + failures.clear(); + const second = await read(first.nextCursor); + expect(second.nextCursor).toBeNull(); + expect(second.items).toHaveLength(1); + } +); +it('AC4 rejects a cursor from another actor, account, grant, state or surface', async () => { + nextRows.set('pullRequests', [providerReview]); + const selected = await auth(); + const first = await listBitbucketInbox(selected); + for (const changed of [ + { ...selected, actor: { ...selected.actor, id: 'other' } }, + { ...selected, userId: 'other' }, + { ...selected, scopes: ['pullrequest'] as typeof selected.scopes }, + ]) + await expect(listBitbucketInbox(changed, { cursor: first.nextCursor })).rejects.toMatchObject({ + code: 'invalid_pagination', + }); + await expect( + listBitbucketInbox(selected, { state: 'MERGED', cursor: first.nextCursor }) + ).rejects.toMatchObject({ code: 'invalid_pagination' }); + await expect( + listBitbucketFiles(selected, identity, revision, first.nextCursor) + ).rejects.toMatchObject({ code: 'invalid_pagination' }); +}); +it.each([ + 'https://evil.example/page', + `${apiRoot}/pullrequests/8/comments?pagelen=50&page=2`, + `${apiRoot}/pullrequests?pagelen=50&state=MERGED&page=2`, +])('AC4 rejects unsafe provider pagination %s', async url => { + nextLinks.set('pullRequests', url); + await expect(listBitbucketInbox(await auth())).rejects.toMatchObject({ + code: 'invalid_pagination', + }); +}); +it('AC4 rejects a foreign-origin caller cursor even with its correct scope key', async () => { + nextRows.set('pullRequests', [providerReview]); + const selected = await auth(); + const first = await listBitbucketInbox(selected); + await expect( + listBitbucketInbox(selected, { + cursor: { + ...first.nextCursor!, + token: JSON.stringify({ count: 1, next: 'https://evil.example/page' }), + }, + }) + ).rejects.toMatchObject({ code: 'invalid_pagination' }); +}); +it.each(['statuses', 'commits', 'diffstat'] as const)( + 'AC4 never reports an incomplete %s aggregate as empty or complete', + async operation => { + nextRows.set(operation, []); + failures.set(`${operation}:2`, 503); + await expect(getBitbucketReview(await auth(), '7')).rejects.toMatchObject({ + code: 'provider_unavailable', + }); + } +); +it('AC6 groups replies across native pages and does not invent comment-time revisions', async () => { + rows.set('comments', [comment]); + nextRows.set('comments', [ + { id: 2, created_on: comment.created_on, content: { raw: 'Later reply' }, parent: { id: 1 } }, + ]); + const result = await listBitbucketDiscussions(await auth(), identity); + expect(result).toMatchObject({ + nextCursor: null, + items: [ + { + id: '1', + subjectType: 'line', + resolved: true, + outdated: null, + position: null, + file: null, + comments: { + nextCursor: null, + items: [ + { bodyMarkdown: 'Review comment', author: { id: actor.uuid.slice(1, -1) } }, + { bodyMarkdown: 'Later reply', author: null }, + ], + }, + }, + ], + }); + expect(result.items[0].reference.url).toContain('comment-1'); + expect(reviewActionAvailability(result.items[0].capabilities.resolveThread!)).toBe('available'); +}); +it('AC6 keeps deleted actors and unresolved conversations distinct', async () => { + rows.set('comments', [ + { ...comment, inline: null, resolution: null, user: null }, + { ...comment, id: 2, deleted: true, parent: { id: 1 } }, + ]); + const result = await listBitbucketDiscussions(await auth(), identity); + expect(result.items[0]).toMatchObject({ + subjectType: 'conversation', + resolved: false, + outdated: null, + comments: { + items: [ + { author: null, bodyMarkdown: 'Review comment' }, + { author: null, bodyMarkdown: '' }, + ], + }, + }); +}); +it.each(['orphan', 'cycle', 'duplicate', 'wrong-review'] as const)( + 'AC6 rejects %s comment data instead of losing discussion', + async defect => { + rows.set( + 'comments', + defect === 'orphan' + ? [{ ...comment, parent: { id: 99 } }] + : defect === 'cycle' + ? [{ ...comment, parent: { id: 1 } }] + : defect === 'duplicate' + ? [comment, comment] + : [{ ...comment, pullrequest: { id: 8 } }] + ); + await expect(listBitbucketDiscussions(await auth(), identity)).rejects.toMatchObject({ + code: defect === 'wrong-review' ? 'repository_mismatch' : 'invalid_response', + }); + } +); +it.each([ + { reviewId: '8' }, + { number: '8' }, + { canonicalUrl: 'https://other.example/review' }, + { authorization: { ...authorization, integrationId: '99999999-9999-4999-8999-999999999999' } }, + { + authorization: { + ...authorization, + owner: { type: 'org', id: '99999999-9999-4999-8999-999999999999' }, + }, + }, + { repository: { ...repository, repositoryId: '99999999-9999-4999-8999-999999999999' } }, + { repository: { ...repository, workspaceUuid: '99999999-9999-4999-8999-999999999999' } }, +])('AC4–AC6 rejects a substituted review identity %j', async change => { + await expect( + listBitbucketDiscussions(await auth(), { ...identity, ...change } as ReviewIdentity) + ).rejects.toMatchObject({ code: 'repository_mismatch' }); +}); +it.each(['id', 'repository', 'workspace'] as const)( + 'AC4 rejects live review %s collisions', + async field => { + if (field === 'id') review.id = 8; + if (field === 'repository') + review.destination.repository.uuid = '{99999999-9999-4999-8999-999999999999}'; + if (field === 'workspace') + review.destination.repository.workspace.uuid = '{99999999-9999-4999-8999-999999999999}'; + await expect(getBitbucketReview(await auth(), '7')).rejects.toMatchObject({ + code: field === 'workspace' ? 'workspace_mismatch' : 'repository_mismatch', + }); + } +); +it('AC4 stops revoked authorization during a read without exporting stale data', async () => { + const selected = await auth(); + failures.set('pullRequest', 401); + const error = await getBitbucketChecks(selected, identity).catch((error: unknown) => error); + expect(error).toMatchObject({ code: 'authentication_rejected' }); + expect(JSON.stringify(error)).not.toContain('provider-secret'); +}); +it('AC6 does not falsely claim a workspace token has no approval or change request', async () => { + const result = await getBitbucketReview(await auth(), '7'); + for (const action of ['unapprove', 'removeChangeRequest'] as const) { + expect(reviewActionAvailability(result.authorization.capabilities[action])).toBe('available'); + expect(result.authorization.capabilities[action].explanation).toBe('participant_actor_unknown'); + } +}); +it.each([0, 1])( + 'AC4 uses the documented open task count %s for enforced checks', + async task_count => { + Object.assign(review, { task_count }); + rows.set('restrictions', [ + { kind: 'enforce_merge_checks', pattern: '*' }, + { kind: 'require_tasks_to_be_completed', pattern: '*' }, + ]); + const result = await getBitbucketReview(await auth(), '7'); + expect(result.authorization.capabilities.merge.restrictions).toEqual( + task_count === 0 ? [] : ['open_tasks'] + ); + } +); +it.each(['repository', 'workspace', 'branch'] as const)( + 'AC5 rejects a file cursor after source %s replacement at the same head', + async field => { + nextRows.set('diffstat', []); + const selected = await auth(); + const first = await listBitbucketFiles(selected, identity, revision); + if (field === 'repository') + review.source.repository.uuid = '{99999999-9999-4999-8999-999999999999}'; + if (field === 'workspace') + review.source.repository.workspace.uuid = '{99999999-9999-4999-8999-999999999999}'; + if (field === 'branch') review.source.branch.name = 'other-branch'; + await expect( + listBitbucketFiles(selected, identity, revision, first.nextCursor) + ).rejects.toMatchObject({ code: 'invalid_pagination' }); + } +); +it('AC6 rejects a discussion cursor after source replacement at the same head', async () => { + rows.set( + 'comments', + Array.from({ length: 26 }, (_, index) => ({ ...comment, id: index + 1 })) + ); + const selected = await auth(); + const first = await listBitbucketDiscussions(selected, identity); + review.source.repository.uuid = '{99999999-9999-4999-8999-999999999999}'; + await expect( + listBitbucketDiscussions(selected, identity, first.nextCursor) + ).rejects.toMatchObject({ code: 'invalid_pagination' }); +}); +it('AC6 rejects missing live comment content instead of fabricating an empty comment', async () => { + rows.set('comments', [{ ...comment, content: undefined }]); + await expect(listBitbucketDiscussions(await auth(), identity)).rejects.toMatchObject({ + code: 'invalid_response', + }); +}); +it('AC5 reads immutable diffstat commit fields without requiring optional links', async () => { + rows.set('diffstat', [ + { + ...stat, + old: { path: 'src/old.ts', commit: { hash: fileRevision.baseSha } }, + new: { path: 'src/new.ts', commit: { hash: revision.headSha } }, + }, + ]); + const selected = await auth(); + expect((await listBitbucketFiles(selected, identity, revision)).items[0].revision).toEqual( + fileRevision + ); + expect((await getBitbucketFileContext(selected, identity, context)).lines).toEqual([ + 'second', + 'third', + ]); +}); +it.each([ + 'https://evil.example/src/old.ts', + `${apiRoot.replace(encodeURIComponent(destination.uuid), 'other')}/src/${fileRevision.baseSha}/src/old.ts`, + `${apiRoot}/src/${fileRevision.baseSha}/wrong.ts`, + `${apiRoot}/src/main/src/old.ts`, +])('AC5 rejects an unsafe or mutable old entry link %s', async href => { + rows.set('diffstat', [{ ...stat, old: { path: 'src/old.ts', links: { self: { href } } } }]); + await expect(listBitbucketFiles(await auth(), identity, revision)).rejects.toMatchObject({ + code: 'invalid_response', + }); +}); +it('AC6 bounds combined comment pages without silently dropping replies', async () => { + rows.set('comments', [{ ...comment, content: { raw: 'x'.repeat(550000) } }]); + nextRows.set('comments', [ + { ...comment, id: 2, parent: { id: 1 }, content: { raw: 'y'.repeat(550000) } }, + ]); + await expect(listBitbucketDiscussions(await auth(), identity)).rejects.toMatchObject({ + code: 'response_too_large', + }); +}); +it.each(['diff', 'file'] as const)( + 'AC5 retains the exact provider link when %s bytes are not UTF-8', + async operation => { + rows.set(operation, new Uint8Array([255])); + const selected = await auth(); + const result = + operation === 'diff' + ? (await listBitbucketFiles(selected, identity, revision)).items[0] + : await getBitbucketFileContext(selected, identity, context); + expect(result).toMatchObject({ + content: 'unavailable', + canonicalUrl: `https://bitbucket.org/fork/repo/src/${revision.headSha}/src/new.ts`, + }); + } +); +it('AC4 ignores branching-model push rules when describing merge restrictions', async () => { + rows.set('restrictions', [ + { kind: 'push', branch_match_kind: 'branching_model', pattern: '', users: [], groups: [] }, + ]); + expect( + (await getBitbucketReview(await auth(), '7')).authorization.capabilities.merge.restrictions + ).toEqual([]); +}); +it('AC4 refuses to return a next link that cannot fit the shared cursor', async () => { + const prefix = `${apiRoot}/pullrequests?state=OPEN&pagelen=50&cursor=`; + nextLinks.set('pullRequests', prefix + 'x'.repeat(4080 - prefix.length)); + await expect(listBitbucketInbox(await auth())).rejects.toMatchObject({ + code: 'invalid_pagination', + }); +}); +it('AC4 retains documented summary text when the description field is absent', async () => { + Object.assign(review, { description: undefined, summary: { raw: 'Documented review body' } }); + expect((await getBitbucketReview(await auth(), '7')).bodyMarkdown).toBe('Documented review body'); +}); +it('AC4 does not advertise branch deletion without a known default branch', async () => { + rows.set('repository', { ...destination, mainbranch: null }); + review.source.repository = structuredClone(destination); + rows.set('diffstat', []); + const result = await getBitbucketReview(await auth(), '7'); + expect(result.authorization.capabilities.deleteBranch).toMatchObject({ + restrictions: ['default_branch_unknown'], + explanation: 'default_branch_unknown', + recovery: 'refresh', + }); + expect(reviewActionAvailability(result.authorization.capabilities.deleteBranch)).toBe( + 'restricted' + ); +}); +it('AC4 treats non-wildcard pattern characters literally', async () => { + review.destination.branch.name = 'release/stable.v1'; + rows.set('branch', { name: review.destination.branch.name, merge_strategies: ['merge_commit'] }); + rows.set('restrictions', [ + { kind: 'restrict_merges', pattern: 'release/stable?v1', users: [], groups: [] }, + { kind: 'restrict_merges', pattern: 'release/stable[v1]', users: [], groups: [] }, + ]); + expect( + (await getBitbucketReview(await auth(), '7')).authorization.capabilities.merge.restrictions + ).toEqual([]); +}); + +// Bitbucket returns an empty commit list after source branch deletion. +// https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/#api-repositories-workspace-repo-slug-pullrequests-pull-request-id-commits-get +it.each(['MERGED', 'DECLINED', 'SUPERSEDED'])( + 'c2-r3 AC4 reads an abbreviated %s review after source branch deletion', + async state => { + review.state = state; + review.source.commit.hash = revision.headSha.slice(0, 12); + Object.assign(review.source, { branch: null }); + rows.set('commits', []); + const selected = await auth(); + const overview = await getBitbucketReview(selected, '7'); + expect(overview).toMatchObject({ + state: state === 'MERGED' ? 'merged' : 'closed', + revision, + source: { branch: null }, + counts: { commits: 0, files: 1 }, + }); + expect((await listBitbucketFiles(selected, identity, revision)).items[0]).toMatchObject({ + content: 'available', + revision: fileRevision, + patch, + }); + expect(await getBitbucketFileContext(selected, identity, context)).toMatchObject({ + content: 'available', + revision: fileRevision, + lines: ['second', 'third'], + canonicalUrl: `https://bitbucket.org/fork/repo/src/${revision.headSha}/src/new.ts`, + }); + expect(await getBitbucketChecks(selected, identity, revision)).toEqual({ + status: 'none', + checks: [], + }); + expect( + (await listBitbucketDiscussions(selected, identity)).items[0].comments.items + ).toHaveLength(2); + } +); +it.each([ + {}, + { hash: null }, + { hash: 123 }, + { hash: 'a'.repeat(12) }, + { hash: 'g'.repeat(40) }, + { hash: 'a'.repeat(41) }, + { hash: 'd'.repeat(40) }, +])('c2-r3 AC4 rejects invalid resolved source commit %j', async data => { + review.source.commit.hash = revision.headSha.slice(0, 12); + rows.set('commits', []); + rows.set(`commit:${review.source.commit.hash}`, data); + await expect(getBitbucketChecks(await auth(), identity)).rejects.toMatchObject({ + code: 'invalid_response', + }); +}); +it.each([ + ['commit', 401, 'authentication_rejected'], + ['commit', 403, 'insufficient_permissions'], + ['commit', 404, 'not_found'], + ['commit', 429, 'rate_limited'], + ['commit', 503, 'provider_unavailable'], + ['commits', 403, 'insufficient_permissions'], + ['commits', 503, 'provider_unavailable'], +] as const)( + 'c2-r3 AC4 preserves %s failure %s during source resolution', + async (operation, status, code) => { + review.source.commit.hash = revision.headSha.slice(0, 12); + rows.set('commits', []); + failures.set(operation, status); + const error = await getBitbucketChecks(await auth(), identity).catch((error: unknown) => error); + expect(error).toMatchObject({ code, message: code }); + expect(JSON.stringify(error)).not.toContain('provider-secret'); + } +); +it('c2-r3 AC5 rejects a resolved prefix collision before reading fork context', async () => { + review.source.commit.hash = revision.headSha.slice(0, 12); + rows.set('commits', []); + rows.set(`commit:${review.source.commit.hash}`, { hash: 'a'.repeat(12) + 'd'.repeat(28) }); + await expect(getBitbucketFileContext(await auth(), identity, context)).rejects.toMatchObject({ + code: 'conflict', + }); +}); +it.each(['revision', 'repository', 'workspace'] as const)( + 'c2-r3 AC5 rejects fork %s drift after abbreviated context resolution', + async field => { + review.source.commit.hash = revision.headSha.slice(0, 12); + rows.set('commits', []); + afterResponse = operation => { + if (operation !== 'file') return; + if (field === 'revision') + rows.set(`commit:${review.source.commit.hash}`, { hash: 'a'.repeat(12) + 'd'.repeat(28) }); + if (field === 'repository') + review.source.repository.uuid = '{99999999-9999-4999-8999-999999999999}'; + if (field === 'workspace') + review.source.repository.workspace.uuid = '{99999999-9999-4999-8999-999999999999}'; + }; + await expect(getBitbucketFileContext(await auth(), identity, context)).rejects.toMatchObject({ + code: 'conflict', + }); + } +); + +// The diff endpoint returns raw git-style hunks, independently of diffstat counts. +// https://developer.atlassian.com/cloud/bitbucket/rest/api-group-commits/#api-repositories-workspace-repo-slug-diff-spec-get +it.each([ + ['missing addition with null counts', '@@ -1 +1,2 @@\n-old\n+first\n', null, null], + ['missing deletion with null counts', '@@ -1,2 +1 @@\n-old\n+first\n', null, null], + ['missing context with matching counts', '@@ -1,2 +1,3 @@\n-old\n+first\n+second\n', 2, 1], + ['empty hunk with zero counts', '@@ -1 +1 @@\n', 0, 0], + ['excess body lines with matching counts', '@@ -1 +1 @@\n-old\n+first\n+extra\n', 2, 1], + ['incomplete earlier hunk', '@@ -1 +1,2 @@\n-old\n+first\n@@ -5 +6 @@\n-later\n+last\n', 2, 2], +] as const)( + 'c2-r3 AC5 marks %s as truncated and retains the provider link', + async (_name, value, lines_added, lines_removed) => { + rows.set('diff', value); + rows.set('diffstat', [{ ...stat, lines_added, lines_removed }]); + expect((await listBitbucketFiles(await auth(), identity, revision)).items[0]).toMatchObject({ + content: 'truncated', + patch: null, + additions: lines_added, + deletions: lines_removed, + canonicalUrl: `https://bitbucket.org/fork/repo/src/${revision.headSha}/src/new.ts`, + }); + } +); +it.each([ + ['complete hunk with null counts', patch, null, null], + ['omitted hunk lengths', '@@ -1 +1 @@\n-old\n+first\n', 1, 1], + ['zero old length', '@@ -0,0 +1,2 @@\n+first\n+second\n', 2, 0], + ['zero new length', '@@ -1,2 +0,0 @@\n-old\n-removed\n', 0, 2], + ['context lines', '@@ -1,2 +1,3 @@\n context\n-old\n+first\n+second\n', 2, 1], + ['multiple hunks', '@@ -1 +1 @@\n-old\n+first\n@@ -5 +5,2 @@\n context\n+second\n', 2, 1], + [ + 'no newline markers', + '@@ -1 +1 @@\n-old\n\\ No newline at end of file\n+first\n\\ No newline at end of file\n', + 1, + 1, + ], + [ + 'rename without hunks', + 'diff --git a/old b/new\nsimilarity index 100%\nrename from old\nrename to new\n', + 0, + 0, + ], + ['confirmed empty patch', '', 0, 0], +] as const)( + 'c2-r3 AC5 retains an available patch for %s', + async (_name, value, lines_added, lines_removed) => { + rows.set('diff', value); + rows.set('diffstat', [{ ...stat, lines_added, lines_removed }]); + expect((await listBitbucketFiles(await auth(), identity, revision)).items[0]).toMatchObject({ + content: 'available', + patch: value, + additions: lines_added, + deletions: lines_removed, + canonicalUrl: `https://bitbucket.org/fork/repo/src/${revision.headSha}/src/new.ts`, + }); + } +); + +// Only push/restrict_merges rules have actor exceptions; delete rules protect all matching branches. +// https://developer.atlassian.com/cloud/bitbucket/rest/api-group-branch-restrictions/#api-repositories-workspace-repo-slug-branch-restrictions-post +it.each(['feature', 'feat*', '*'])( + 'c2-r3 AC6 restricts source deletion matching %s without changing destination merge policy', + async pattern => { + review.source.repository = structuredClone(destination); + rows.set('diffstat', []); + rows.set('restrictions', [ + { kind: 'delete', pattern }, + { kind: 'enforce_merge_checks', pattern: 'release/*' }, + { kind: 'require_approvals_to_merge', pattern: 'release/*', value: 2 }, + ]); + const { capabilities } = (await getBitbucketReview(await auth(), '7')).authorization; + expect(reviewActionAvailability(capabilities.deleteBranch)).toBe('restricted'); + expect(capabilities.deleteBranch).toMatchObject({ + permission: 'allowed', + restrictions: ['source_branch_protected'], + explanation: 'source_branch_protected', + recovery: 'openProvider', + }); + expect(capabilities.merge.restrictions).toEqual(['approvals_required']); + } +); +it.each([ + [{ kind: 'push', pattern: 'feature', users: [], groups: [] }, 'actor_delete_restricted'], + [ + { kind: 'push', pattern: 'feature', users: [], groups: [{ slug: 'developers' }] }, + 'delete_group_membership_unknown', + ], + [ + { kind: 'delete', branch_match_kind: 'branching_model', pattern: '', branch_type: 'feature' }, + 'branching_model_restrictions_unknown', + ], + [ + { + kind: 'push', + branch_match_kind: 'branching_model', + pattern: '', + branch_type: 'feature', + users: [], + groups: [], + }, + 'branching_model_restrictions_unknown', + ], +] as const)('c2-r3 AC6 preserves source restriction evidence %j', async (rule, restriction) => { + oauth(); + review.source.repository = structuredClone(destination); + rows.set('diffstat', []); + rows.set('restrictions', [rule]); + const { capabilities } = (await getBitbucketReview(await auth(), '7')).authorization; + expect(reviewActionAvailability(capabilities.deleteBranch)).toBe('restricted'); + expect(capabilities.deleteBranch).toMatchObject({ + restrictions: [restriction], + explanation: restriction, + recovery: 'openProvider', + }); + expect(capabilities.merge).toMatchObject({ + permission: 'unknown', + restrictions: [], + explanation: 'repository_merge_permission_unknown', + }); +}); +it.each([ + [403, 1], + [404, 1], + [403, 2], +])( + 'c2-r3 AC6 does not advertise deletion after restriction failure %s on page %s', + async (status, page) => { + review.source.repository = structuredClone(destination); + rows.set('diffstat', []); + if (page === 2) nextRows.set('restrictions', []); + failures.set(`restrictions:${page}`, status); + const result = await getBitbucketReview(await auth(), '7'); + expect(result.title).toBe('Fork review'); + const { capabilities } = result.authorization; + expect(reviewActionAvailability(capabilities.deleteBranch)).toBe('restricted'); + expect(capabilities.deleteBranch).toMatchObject({ + restrictions: ['delete_restrictions_unavailable'], + explanation: 'delete_restrictions_unavailable', + recovery: 'openProvider', + }); + expect(capabilities.merge.restrictions).toEqual(['merge_restrictions_unavailable']); + } +); +it.each([ + ['workspace', [], 'available'], + ['workspace', [{ kind: 'delete', pattern: 'release/*' }], 'available'], + ['oauth', [{ kind: 'push', pattern: 'feature', users: [actor], groups: [] }], 'available'], + ['workspace', [{ kind: 'push', pattern: 'feature', users: [actor], groups: [] }], 'restricted'], +] as const)( + 'c2-r3 AC6 retains %s actor permissions for source rules %j', + async (kind, rules, availability) => { + if (kind === 'oauth') oauth(); + review.source.repository = structuredClone(destination); + rows.set('diffstat', []); + rows.set('restrictions', rules); + const { capabilities } = (await getBitbucketReview(await auth(), '7')).authorization; + expect(reviewActionAvailability(capabilities.deleteBranch)).toBe(availability); + expect(capabilities.merge.restrictions).toEqual([]); + } +); +it.each([ + ['delete', [], 'restricted'], + ['push', [], 'restricted'], + ['push', [actor], 'available'], +] as const)( + 'c2-r3 AC6 handles a branching-model %s rule without a glob pattern and users %j', + async (kind, users, availability) => { + oauth(); + review.source.repository = structuredClone(destination); + rows.set('diffstat', []); + rows.set('restrictions', [ + { kind, branch_match_kind: 'branching_model', branch_type: 'feature', users, groups: [] }, + ]); + const { capabilities } = (await getBitbucketReview(await auth(), '7')).authorization; + expect(reviewActionAvailability(capabilities.deleteBranch)).toBe(availability); + expect(capabilities.deleteBranch.restrictions).toEqual( + availability === 'available' ? [] : ['branching_model_restrictions_unknown'] + ); + expect(capabilities.merge.restrictions).toEqual([]); + } +); +it('c2-r3 AC6 rejects a glob deletion rule without its required pattern', async () => { + review.source.repository = structuredClone(destination); + rows.set('diffstat', []); + rows.set('restrictions', [{ kind: 'delete', branch_match_kind: 'glob' }]); + await expect(getBitbucketReview(await auth(), '7')).rejects.toMatchObject({ + code: 'invalid_response', + }); +}); +it.each(['fork', 'default', 'destination', 'missing'] as const)( + 'c2-r3 AC6 preserves the %s source deletion guard with unprotected controls', + async guard => { + if (guard !== 'fork') review.source.repository = structuredClone(destination); + if (guard === 'default') review.source.branch.name = 'trunk'; + if (guard === 'destination') review.source.branch.name = 'release/stable'; + if (guard === 'missing') Object.assign(review.source, { branch: null }); + rows.set('diffstat', []); + const capability = (await getBitbucketReview(await auth(), '7')).authorization.capabilities + .deleteBranch; + expect(reviewActionAvailability(capability)).toBe('restricted'); + expect(capability.restrictions).toEqual([ + guard === 'fork' + ? 'fork_source_requires_separate_authorization' + : 'source_branch_not_deletable', + ]); + } +); +it.each(['workspace', 'oauth'])( + 'c2-r3 AC6 preserves missing %s write grants beside source protection', + async kind => { + if (kind === 'oauth') oauth(); + metadata.grants.scopes = ['repository', 'pullrequest', 'pullrequest:write']; + review.source.repository = structuredClone(destination); + rows.set('diffstat', []); + rows.set('restrictions', [{ kind: 'delete', pattern: 'feature' }]); + const capability = (await getBitbucketReview(await auth(), '7')).authorization.capabilities + .deleteBranch; + expect(reviewActionAvailability(capability)).toBe('forbidden'); + expect(capability).toMatchObject({ + restrictions: ['source_branch_protected'], + explanation: 'missing_scope:repository:write', + recovery: kind === 'oauth' ? 'reconnect' : 'replaceToken', + }); + } +); diff --git a/apps/web/src/lib/provider-review/bitbucket-read.ts b/apps/web/src/lib/provider-review/bitbucket-read.ts new file mode 100644 index 0000000000..d740205492 --- /dev/null +++ b/apps/web/src/lib/provider-review/bitbucket-read.ts @@ -0,0 +1,1176 @@ +import 'server-only'; + +import { z } from 'zod'; +import { repositoryResourceKey } from '@kilocode/app-shared/code-review/repository-identity'; +import { getMissingBitbucketWorkspaceAccessTokenScopes } from '@kilocode/worker-utils/bitbucket-workspace-access-token'; +import { + ReviewActionSchema, + ReviewCapabilitiesSchema, + ReviewRevisionSchema, + REVIEW_WRITE_REQUEST_MAX_BYTES, + parseReviewCursor, + reviewPageKey, + reviewResourceKey, + type ReviewCapability, + type ReviewCursor, + type ReviewFile, + type ReviewFileContext, + type ReviewIdentity, + type ReviewInbox, + type ReviewInboxItem, + type ReviewOverview, + type ReviewPage, + type ReviewPageScope, + type ReviewRevision, + type ReviewThread, +} from '@kilocode/app-shared/provider-review'; +import { + BitbucketInteractiveClientError, + type BitbucketInteractiveBrokerRequest, +} from '@/lib/integrations/platforms/bitbucket/interactive-client'; +import { + BITBUCKET_MAX_RESPONSE_BYTES, + assertBitbucketUrl, +} from '../../../../../services/git-token-service/src/bitbucket-safe-transport'; +import { + BitbucketPathSchema, + BitbucketProviderRepositorySchema, + BitbucketUserSchema, + BitbucketUuidSchema, + assertBitbucketRepository, + assertBitbucketReviewIdentity, + bitbucketActor, + bitbucketRepository, + parseBitbucket, + type BitbucketReviewAuthorization, +} from './bitbucket-authorization'; + +const id = z.number().int().positive().max(Number.MAX_SAFE_INTEGER); +const sha = z + .string() + .regex(/^[a-f0-9]{40}$/i) + .transform(value => value.toLowerCase()); +const providerSha = z + .string() + .regex(/^[a-f0-9]{7,40}$/i) + .transform(value => value.toLowerCase()); +const link = z.object({ href: z.string() }); +const participant = z.object({ + user: BitbucketUserSchema, + role: z.string().min(1), + state: z.enum(['approved', 'changes_requested']).nullish(), + approved: z.boolean().optional(), + participated_on: z.string().nullish(), +}); +const summarySchema = z.object({ + type: z.literal('pullrequest'), + id, + title: z.string(), + description: z.string().nullish(), + summary: z.object({ raw: z.string().nullish() }).nullish(), + state: z.enum(['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED']), + draft: z.boolean().optional(), + updated_on: z.string(), + author: BitbucketUserSchema.nullish(), + links: z.object({ html: link }), + destination: z.object({ + repository: z.object({ + uuid: BitbucketProviderRepositorySchema.shape.uuid, + full_name: BitbucketProviderRepositorySchema.shape.full_name, + workspace: BitbucketProviderRepositorySchema.shape.workspace.optional(), + }), + }), +}); +const reviewSchema = summarySchema.extend({ + source: z.object({ + repository: BitbucketProviderRepositorySchema.nullable(), + branch: z.object({ name: z.string().min(1) }).nullable(), + commit: z.object({ hash: providerSha }), + }), + destination: z.object({ + repository: BitbucketProviderRepositorySchema, + branch: z.object({ name: BitbucketPathSchema }), + commit: z.object({ hash: providerSha }), + }), + participants: z.array(participant), + task_count: z.number().int().nonnegative().optional(), +}); +const entrySchema = z.object({ + path: BitbucketPathSchema, + commit: z.object({ hash: sha }).optional(), + attributes: z.array(z.string()).optional(), + links: z.object({ self: link.optional() }).optional(), +}); +const diffstatSchema = z + .object({ + status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed']), + old: entrySchema.nullish(), + new: entrySchema.nullish(), + lines_added: z.number().int().nonnegative().nullish(), + lines_removed: z.number().int().nonnegative().nullish(), + }) + .refine(value => value.old != null || value.new != null); +const statusSchema = z.object({ + key: z.string().min(1), + state: z.string(), + name: z.string().nullish(), + url: z.string().nullish(), +}); +const restrictionSchema = z.object({ + kind: z.string().min(1), + branch_match_kind: z.enum(['glob', 'branching_model']).default('glob'), + pattern: z.string().optional(), + value: z.number().int().nonnegative().optional(), + users: z.array(z.object({ uuid: BitbucketUuidSchema })).optional(), + groups: z.array(z.object({ slug: z.string().optional() })).optional(), +}); +const commentSchema = z + .object({ + id, + created_on: z.string(), + content: z.object({ raw: z.string() }).nullish(), + deleted: z.boolean().optional(), + user: BitbucketUserSchema.nullish(), + parent: z.object({ id }).nullish(), + inline: z + .object({ + path: BitbucketPathSchema, + from: id.nullish(), + to: id.nullish(), + start_from: id.nullish(), + start_to: id.nullish(), + }) + .nullish(), + resolution: z.object({}).nullish(), + pullrequest: z.object({ id }).optional(), + }) + .refine(value => value.deleted === true || value.content != null); +const docs = 'https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/'; +type PageRequest = BitbucketInteractiveBrokerRequest< + 'pullRequests' | 'diffstat' | 'statuses' | 'comments' | 'commits' | 'restrictions' +>; + +function bounded(value: T): T { + if (Buffer.byteLength(JSON.stringify(value), 'utf8') > BITBUCKET_MAX_RESPONSE_BYTES) + throw new BitbucketInteractiveClientError('response_too_large'); + return value; +} +function scope( + auth: BitbucketReviewAuthorization, + surface: ReviewPageScope['surface'], + queryKey: string, + identity?: ReviewIdentity, + revision: ReviewRevision | null = null +): ReviewPageScope { + return { + resourceKey: JSON.stringify([ + identity + ? reviewResourceKey(auth.userId, identity) + : repositoryResourceKey(auth.userId, auth), + auth.actor.id, + auth.credentialKind, + [...auth.scopes].sort(), + ]), + surface, + queryKey, + revision, + }; +} +function pagePath(auth: BitbucketReviewAuthorization, request: PageRequest) { + const base = `/2.0/repositories/${encodeURIComponent(auth.path.workspace)}/${encodeURIComponent(auth.path.repo_slug)}`; + if (request.operation === 'pullRequests') return `${base}/pullrequests`; + if (request.operation === 'restrictions') return `${base}/branch-restrictions`; + if (request.operation === 'diffstat') + return `${base}/diffstat/${encodeURIComponent(request.params.path.spec)}`; + return `${base}/pullrequests/${request.params.path.pull_request_id}/${request.operation}`; +} +function pageUrl(value: string, path: string) { + try { + // Leave room for the page counter and JSON envelope in the shared 4096-character cursor. + parseBitbucket(z.string().max(4000), value, 'invalid_pagination'); + return assertBitbucketUrl(value, path).href; + } catch { + throw new BitbucketInteractiveClientError('invalid_pagination'); + } +} +async function page( + auth: BitbucketReviewAuthorization, + request: PageRequest, + schema: z.ZodType, + bound: ReviewPageScope, + cursor?: ReviewCursor | null +): Promise> { + let continuation: { count: number; next: string } | undefined; + if (cursor) { + try { + continuation = z + .object({ count: id.max(99), next: z.string().min(1).max(4000) }) + .parse(JSON.parse(parseReviewCursor(cursor, bound).token)); + } catch { + throw new BitbucketInteractiveClientError('invalid_pagination'); + } + pageUrl(continuation.next, pagePath(auth, request)); + } + const result = await auth.client.execute({ + ...request, + ...(continuation ? { next: continuation.next } : {}), + }); + if (result.status !== 200) throw new BitbucketInteractiveClientError('invalid_response'); + const data = parseBitbucket( + z.object({ values: z.array(schema).max(50), next: z.string().optional() }), + result.data + ); + if (data.next !== result.next) throw new BitbucketInteractiveClientError('invalid_response'); + const count = (continuation?.count ?? 0) + 1; + if (result.next && count >= 100) throw new BitbucketInteractiveClientError('page_limit_exceeded'); + const next = result.next ? pageUrl(result.next, pagePath(auth, request)) : null; + if (next && next === continuation?.next) + throw new BitbucketInteractiveClientError('invalid_pagination'); + return bounded({ + items: data.values, + nextCursor: next + ? { scopeKey: reviewPageKey(bound), token: JSON.stringify({ count, next }) } + : null, + }); +} +async function collect( + auth: BitbucketReviewAuthorization, + request: PageRequest, + schema: z.ZodType, + bound: ReviewPageScope +): Promise { + const items: T[] = []; + const seen = new Set(); + let cursor: ReviewCursor | null = null; + do { + const result: ReviewPage = await page(auth, request, schema, bound, cursor); + items.push(...result.items); + if (items.length > 5000) throw new BitbucketInteractiveClientError('item_limit_exceeded'); + bounded(items); + cursor = result.nextCursor; + if (cursor) { + const next = parseBitbucket(z.object({ next: z.string() }), JSON.parse(cursor.token)).next; + if (seen.has(next)) throw new BitbucketInteractiveClientError('invalid_pagination'); + seen.add(next); + } + } while (cursor); + return items; +} +function item( + auth: BitbucketReviewAuthorization, + review: z.infer +): ReviewInboxItem { + const destination = review.destination.repository; + if ( + destination.uuid !== auth.repository.repositoryId || + destination.full_name !== auth.repository.fullName + ) + throw new BitbucketInteractiveClientError('repository_mismatch'); + if (destination.workspace && destination.workspace.uuid !== auth.repository.workspaceUuid) + throw new BitbucketInteractiveClientError('workspace_mismatch'); + const number = String(review.id); + const canonicalUrl = `https://bitbucket.org/${auth.repository.fullName}/pull-requests/${number}`; + if (review.links.html.href !== canonicalUrl) + throw new BitbucketInteractiveClientError('repository_mismatch'); + return { + identity: { + repository: auth.repository, + authorization: auth.authorization, + reviewId: number, + number, + canonicalUrl, + }, + title: review.title, + author: review.author ? bitbucketActor(review.author) : null, + state: review.state === 'OPEN' ? 'open' : review.state === 'MERGED' ? 'merged' : 'closed', + draft: review.draft ?? false, + updatedAt: review.updated_on, + }; +} +export async function listBitbucketInbox( + auth: BitbucketReviewAuthorization, + input: { + cursor?: ReviewCursor | null; + state?: 'OPEN' | 'MERGED' | 'DECLINED' | 'SUPERSEDED'; + } = {} +): Promise { + const state = parseBitbucket( + z.enum(['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED']), + input.state ?? 'OPEN', + 'invalid_request' + ); + const result = await page( + auth, + { operation: 'pullRequests', params: { path: auth.path, query: { state } } }, + summarySchema, + scope(auth, 'inbox', state), + input.cursor + ); + return bounded({ + ...result, + items: result.items.map(review => item(auth, review)), + scope: { kind: 'repository', actor: auth.actor, repository: auth.repository }, + }); +} +async function resolveCommit(auth: BitbucketReviewAuthorization, hash: string) { + const result = await auth.client.execute({ + operation: 'commit', + params: { path: { ...auth.path, commit: hash } }, + }); + if (result.status !== 200) throw new BitbucketInteractiveClientError('invalid_response'); + const commit = parseBitbucket(z.object({ hash: sha }), result.data); + if (!commit.hash.startsWith(hash)) throw new BitbucketInteractiveClientError('invalid_response'); + return commit.hash; +} +async function load(auth: BitbucketReviewAuthorization, number: string) { + const numeric = parseBitbucket( + id, + Number(parseBitbucket(z.string().regex(/^[1-9]\d*$/), number, 'invalid_request')), + 'invalid_request' + ); + const path = { ...auth.path, pull_request_id: numeric }; + const response = await auth.client.execute({ + operation: 'pullRequest', + params: { + path, + query: { fields: '+source.repository.workspace,+destination.repository.workspace' }, + }, + }); + if (response.status !== 200) throw new BitbucketInteractiveClientError('invalid_response'); + const review = parseBitbucket(reviewSchema, response.data); + if (review.id !== numeric) throw new BitbucketInteractiveClientError('repository_mismatch'); + assertBitbucketRepository(auth.repository, review.destination.repository); + const summary = item(auth, review); + let headSha = review.source.commit.hash; + let targetHeadSha = review.destination.commit.hash; + if (headSha.length !== 40) { + const commits = await collect( + auth, + { operation: 'commits', params: { path } }, + z.object({ hash: sha }), + scope(auth, 'files', 'resolve-head', summary.identity) + ); + const candidates = commits.filter(commit => commit.hash.startsWith(headSha)); + if (candidates.length > 1) throw new BitbucketInteractiveClientError('temporarily_unavailable'); + // Deleted source branches can leave this list empty. Resolve only through the authorized repository. + headSha = candidates[0]?.hash ?? (await resolveCommit(auth, headSha)); + } + if (targetHeadSha.length !== 40) targetHeadSha = await resolveCommit(auth, targetHeadSha); + const revision: ReviewRevision = { headSha, targetHeadSha, baseSha: null, startSha: null }; + return { + auth, + review, + summary, + identity: summary.identity, + path, + revision, + source: review.source.repository ? bitbucketRepository(review.source.repository) : null, + }; +} +type Loaded = Awaited>; +function heads(selected: ReviewRevision, actual: ReviewRevision) { + const value = parseBitbucket(ReviewRevisionSchema, selected, 'invalid_request'); + if ( + value.headSha !== actual.headSha || + value.targetHeadSha !== actual.targetHeadSha || + value.startSha !== null + ) + throw new BitbucketInteractiveClientError('conflict'); +} +async function exact(auth: BitbucketReviewAuthorization, identity: ReviewIdentity) { + assertBitbucketReviewIdentity(auth, identity); + return load(auth, identity.number); +} +async function unchanged(loaded: Loaded) { + const current = await exact(loaded.auth, loaded.identity); + heads(loaded.revision, current.revision); + if ( + loaded.source?.repositoryId !== current.source?.repositoryId || + loaded.source?.workspaceUuid !== current.source?.workspaceUuid || + loaded.source?.fullName !== current.source?.fullName || + loaded.review.source.branch?.name !== current.review.source.branch?.name || + loaded.review.destination.branch.name !== current.review.destination.branch.name + ) + throw new BitbucketInteractiveClientError('conflict'); +} +function diffRequest(loaded: Loaded, path?: string): BitbucketInteractiveBrokerRequest<'diffstat'> { + return { + operation: 'diffstat', + // Bitbucket's order is the reverse of git diff. topic=true compares the source with its merge base. + params: { + path: { + ...loaded.auth.path, + spec: `${loaded.revision.headSha}..${loaded.revision.targetHeadSha}`, + }, + query: { topic: true, ...(path ? { path } : {}) }, + }, + }; +} +function entryCommit( + loaded: Loaded, + entry: z.infer | null | undefined +): string | null { + if (!entry) return null; + let commit = entry.commit?.hash ?? null; + const href = entry.links?.self?.href; + if (href) { + let url: URL; + try { + url = new URL(href); + assertBitbucketUrl(href, url.pathname); + const parts = url.pathname.split('/').map(decodeURIComponent); + const repository = [loaded.auth.repository, loaded.source].find( + repository => + repository && + ((parts[3] === repository.fullName.split('/')[0] && + parts[4] === repository.fullName.split('/')[1]) || + (parts[3] === `{${repository.workspaceUuid}}` && + parts[4] === `{${repository.repositoryId}}`)) + ); + if ( + !repository || + parts[2] !== 'repositories' || + parts[5] !== 'src' || + parts.slice(7).join('/') !== entry.path || + url.search + ) + throw new Error('identity'); + const linked = parseBitbucket(sha, parts[6]); + if (commit && commit !== linked) throw new Error('revision'); + commit = linked; + } catch { + throw new BitbucketInteractiveClientError('invalid_response'); + } + } + return commit; +} +function fileUrl(loaded: Loaded, side: 'old' | 'new', path: string, commit: string | null) { + const repository = side === 'old' ? loaded.auth.repository : loaded.source; + return repository && commit + ? `https://bitbucket.org/${repository.fullName}/src/${commit}/${path.split('/').map(encodeURIComponent).join('/')}` + : `${loaded.identity.canonicalUrl}/diff`; +} +function fileFromStat(loaded: Loaded, stat: z.infer): ReviewFile { + const oldPath = stat.old?.path ?? null; + const newPath = stat.new?.path ?? null; + const newCommit = entryCommit(loaded, stat.new); + if (newCommit && newCommit !== loaded.revision.headSha) + throw new BitbucketInteractiveClientError('conflict'); + const baseSha = entryCommit(loaded, stat.old); + const binary = [...(stat.old?.attributes ?? []), ...(stat.new?.attributes ?? [])].includes( + 'binary' + ); + return { + id: JSON.stringify([oldPath, newPath]), + oldPath, + newPath, + // The PR omits its merge base. Preserve the immutable old entry revision when diffstat supplies it. + revision: { ...loaded.revision, baseSha }, + status: stat.status === 'removed' ? 'deleted' : stat.status, + additions: stat.lines_added ?? null, + deletions: stat.lines_removed ?? null, + patch: null, + content: binary ? 'binary' : 'unavailable', + canonicalUrl: fileUrl( + loaded, + newPath ? 'new' : 'old', + newPath ?? oldPath ?? '', + newPath ? loaded.revision.headSha : baseSha + ), + }; +} +async function withPatch(loaded: Loaded, file: ReviewFile): Promise { + if (file.content === 'binary') return file; + const request = diffRequest(loaded, file.newPath ?? file.oldPath ?? undefined); + try { + const response = await loaded.auth.client.execute({ ...request, operation: 'diff' }); + if (response.status !== 200) throw new BitbucketInteractiveClientError('invalid_response'); + const patch = parseBitbucket(z.string(), response.data); + if (/(^|\n)(Binary files |GIT binary patch)/.test(patch)) return { ...file, content: 'binary' }; + let additions = 0, + deletions = 0, + oldRemaining = 0, + newRemaining = 0, + hunk = false; + // Diffstat can omit counts and never counts unchanged context lines. + for (const line of patch.split(/\r?\n/)) { + if (line.startsWith('@@') || line.startsWith('diff --git ')) { + if (oldRemaining !== 0 || newRemaining !== 0) return { ...file, content: 'truncated' }; + hunk = line.startsWith('@@'); + if (!hunk) continue; + const header = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@(?: .*)?$/.exec(line); + if (!header) return file; + oldRemaining = Number(header[1] ?? 1); + newRemaining = Number(header[2] ?? 1); + } else if (hunk) { + if (line.startsWith('+')) { + additions++; + newRemaining--; + } else if (line.startsWith('-')) { + deletions++; + oldRemaining--; + } else if (line.startsWith(' ')) { + oldRemaining--; + newRemaining--; + } else if (line !== '' && line !== '\\ No newline at end of file') return file; + if (oldRemaining < 0 || newRemaining < 0) return { ...file, content: 'truncated' }; + } + } + if ( + oldRemaining !== 0 || + newRemaining !== 0 || + (file.additions !== null && additions !== file.additions) || + (file.deletions !== null && deletions !== file.deletions) + ) + return { ...file, content: 'truncated' }; + if (!patch && !(file.additions === 0 && file.deletions === 0)) return file; + return { ...file, patch, content: 'available' }; + } catch (error) { + if (error instanceof BitbucketInteractiveClientError && error.code === 'response_too_large') + return { ...file, content: 'truncated' }; + if ( + error instanceof BitbucketInteractiveClientError && + ['not_found', 'invalid_response'].includes(error.code) + ) + return file; + throw error; + } +} +export async function listBitbucketFiles( + auth: BitbucketReviewAuthorization, + identity: ReviewIdentity, + selected: ReviewRevision, + cursor?: ReviewCursor | null +): Promise> { + const loaded = await exact(auth, identity); + heads(selected, loaded.revision); + const result = await page( + auth, + diffRequest(loaded), + diffstatSchema, + scope( + auth, + 'files', + JSON.stringify([ + 'topic', + loaded.source, + loaded.review.source.branch, + loaded.review.destination.branch, + ]), + identity, + selected + ), + cursor + ); + const items: ReviewFile[] = []; + for (const stat of result.items) { + items.push(await withPatch(loaded, fileFromStat(loaded, stat))); + bounded(items); + } + await unchanged(loaded); + return bounded({ ...result, items }); +} +export async function getBitbucketFileContext( + auth: BitbucketReviewAuthorization, + identity: ReviewIdentity, + input: { + file: Pick; + side: 'old' | 'new'; + startLine: number; + lineCount: number; + } +): Promise { + parseBitbucket( + z.object({ + side: z.enum(['old', 'new']), + startLine: id, + lineCount: id.max(500), + file: z.object({ + oldPath: BitbucketPathSchema.nullable(), + newPath: BitbucketPathSchema.nullable(), + revision: ReviewRevisionSchema, + }), + }), + input, + 'invalid_request' + ); + const loaded = await exact(auth, identity); + heads(input.file.revision, loaded.revision); + const path = input.side === 'old' ? input.file.oldPath : input.file.newPath; + if (!path) throw new BitbucketInteractiveClientError('invalid_request'); + const stats = await collect( + auth, + diffRequest(loaded, input.file.newPath ?? input.file.oldPath ?? undefined), + diffstatSchema, + scope(auth, 'context', path, identity, input.file.revision) + ); + const stat = stats.find( + stat => + (stat.old?.path ?? null) === input.file.oldPath && + (stat.new?.path ?? null) === input.file.newPath + ); + if (!stat) throw new BitbucketInteractiveClientError('conflict'); + const file = fileFromStat(loaded, stat); + if (file.revision.baseSha !== input.file.revision.baseSha) + throw new BitbucketInteractiveClientError('conflict'); + const commit = input.side === 'old' ? file.revision.baseSha : file.revision.headSha; + let result: ReviewFileContext = { + revision: file.revision, + path, + side: input.side, + startLine: input.startLine, + lines: [], + totalLines: null, + content: 'unavailable', + canonicalUrl: fileUrl(loaded, input.side, path, commit), + }; + if (commit && (input.side === 'old' || loaded.source)) { + const source = + input.side === 'new' && loaded.source + ? { + pullRequestId: loaded.path.pull_request_id, + workspaceUuid: loaded.source.workspaceUuid, + repositoryUuid: loaded.source.repositoryId, + } + : undefined; + const params = { path: { ...auth.path, commit, path } }; + try { + const response = await auth.client.execute({ + operation: 'fileMetadata', + params, + ...(source ? { source } : {}), + }); + if (response.status !== 200) throw new BitbucketInteractiveClientError('invalid_response'); + const metadata = parseBitbucket( + z.object({ + type: z.enum(['commit_file', 'commit_directory']), + path: BitbucketPathSchema, + commit: z.object({ hash: providerSha }).optional(), + size: z.number().int().nonnegative().optional(), + attributes: z.array(z.string()).optional(), + }), + response.data + ); + if (metadata.path !== path || (metadata.commit && !commit.startsWith(metadata.commit.hash))) + throw new BitbucketInteractiveClientError('conflict'); + if (metadata.attributes?.includes('binary')) result.content = 'binary'; + else if (metadata.size !== undefined && metadata.size > BITBUCKET_MAX_RESPONSE_BYTES) + result.content = 'truncated'; + else if ( + metadata.type === 'commit_file' && + !metadata.attributes?.some(attribute => ['link', 'subrepository'].includes(attribute)) + ) { + const raw = await auth.client.execute({ + operation: 'file', + params, + ...(source ? { source } : {}), + }); + if (raw.status !== 200) throw new BitbucketInteractiveClientError('invalid_response'); + const text = parseBitbucket(z.string(), raw.data); + if (text.includes('\0')) result.content = 'binary'; + else if (metadata.size !== undefined && Buffer.byteLength(text, 'utf8') !== metadata.size) + result.content = 'truncated'; + else { + const lines = text === '' ? [] : text.replace(/\r?\n$/, '').split(/\r?\n/); + result = bounded({ + ...result, + content: 'available', + totalLines: lines.length, + lines: lines.slice(input.startLine - 1, input.startLine - 1 + input.lineCount), + }); + } + } + } catch (error) { + if (!(error instanceof BitbucketInteractiveClientError)) throw error; + if (error.code === 'response_too_large') result.content = 'truncated'; + else if (!['not_found', 'insufficient_permissions', 'invalid_response'].includes(error.code)) + throw error; + } + } + await unchanged(loaded); + return result; +} +async function checks(loaded: Loaded): Promise { + try { + const values = await collect( + loaded.auth, + { operation: 'statuses', params: { path: loaded.path } }, + statusSchema, + scope(loaded.auth, 'checks', 'source', loaded.identity, loaded.revision) + ); + if (!values.length) return { status: 'none', checks: [] }; + return { + status: 'reported', + checks: values.map(value => ({ + id: value.key, + name: value.name || value.key, + state: + value.state === 'SUCCESSFUL' + ? 'passed' + : value.state === 'FAILED' + ? 'failed' + : value.state === 'INPROGRESS' + ? 'running' + : value.state === 'STOPPED' + ? 'cancelled' + : 'unknown', + required: null, + detailsUrl: + value.url && z.url({ protocol: /^https$/ }).safeParse(value.url).success + ? value.url + : null, + })), + }; + } catch (error) { + if ( + error instanceof BitbucketInteractiveClientError && + ['insufficient_permissions', 'not_found'].includes(error.code) + ) + return { status: 'unavailable', explanation: error.code }; + throw error; + } +} +export async function getBitbucketChecks( + auth: BitbucketReviewAuthorization, + identity: ReviewIdentity, + selected?: ReviewRevision +): Promise { + const loaded = await exact(auth, identity); + if (selected) heads(selected, loaded.revision); + const result = await checks(loaded); + await unchanged(loaded); + return bounded(result); +} +function capabilitySet(loaded: Loaded) { + const { auth, review } = loaded; + const own = review.participants?.find(value => value.user.uuid === auth.actor.id); + // Honor implied pullrequest permission without relaxing action-specific write-grant checks. + const hasPullRequestScope = !getMissingBitbucketWorkspaceAccessTokenScopes(auth.scopes).includes( + 'pullrequest' + ); + return ReviewCapabilitiesSchema.parse( + Object.fromEntries( + ReviewActionSchema.options.map(action => { + const grant = [ + 'approve', + 'unapprove', + 'requestChanges', + 'removeChangeRequest', + 'submitReview', + 'merge', + ].includes(action) + ? 'pullrequest:write' + : action === 'deleteBranch' + ? 'repository:write' + : 'pullrequest'; + const allowed = + action === 'read' || + (grant === 'pullrequest' ? hasPullRequestScope : auth.scopes.includes(grant)); + const value: ReviewCapability = { + support: 'supported', + version: 'available', + license: 'available', + permission: allowed ? 'allowed' : 'forbidden', + restrictions: [], + explanation: allowed ? '' : `missing_scope:${grant}`, + evidenceUrl: docs, + recovery: allowed + ? 'none' + : auth.credentialKind === 'bitbucketWorkspaceToken' + ? 'replaceToken' + : 'reconnect', + expectedHeadProtection: 'none', + }; + if ( + [ + 'approve', + 'unapprove', + 'requestChanges', + 'removeChangeRequest', + 'submitReview', + 'merge', + ].includes(action) && + review.state !== 'OPEN' + ) + value.restrictions.push('review_closed'); + if (['approve', 'requestChanges'].includes(action) && review.author?.uuid === auth.actor.id) + value.restrictions.push('review_author'); + if (action === 'unapprove' || action === 'removeChangeRequest') { + // Workspace metadata does not identify the app user's participant UUID. + // It cannot prove that this credential has no approval or change request. + if (!BitbucketUuidSchema.safeParse(auth.actor.id).success) { + if (allowed) value.explanation = 'participant_actor_unknown'; + } else if (action === 'unapprove' && !(own?.state === 'approved' || own?.approved)) { + value.restrictions.push('not_approved'); + } else if (action === 'removeChangeRequest' && own?.state !== 'changes_requested') { + value.restrictions.push('changes_not_requested'); + } + } + if (action === 'submitReview' && allowed) + value.explanation = 'separate_effects_without_atomic_expected_head'; + if (action === 'merge' && review.draft) value.restrictions.push('draft'); + if (action === 'merge' && allowed && auth.credentialKind === 'bitbucketOAuth') { + value.permission = 'unknown'; + value.explanation = 'repository_merge_permission_unknown'; + value.recovery = 'openProvider'; + } + if ( + action === 'deleteBranch' && + (loaded.source?.repositoryId !== auth.repository.repositoryId || + loaded.source?.workspaceUuid !== auth.repository.workspaceUuid) + ) + value.restrictions.push('fork_source_requires_separate_authorization'); + if (action === 'deleteBranch' && auth.repository.defaultBranch === null) { + value.restrictions.push('default_branch_unknown'); + if (allowed) { + value.explanation = 'default_branch_unknown'; + value.recovery = 'refresh'; + } + } + if ( + action === 'deleteBranch' && + (!review.source.branch || + review.source.branch.name === auth.repository.defaultBranch || + review.source.branch.name === review.destination.branch.name) + ) + value.restrictions.push('source_branch_not_deletable'); + const unsupported = + action === 'enableAutoMerge' || action === 'disableAutoMerge' + ? ['BCLOUD-22062', 'auto_merge_scheduling_api_unavailable'] + : action === 'updateBranch' + ? ['BCLOUD-20489', 'branch_sync_api_unavailable'] + : action === 'addReaction' || action === 'removeReaction' + ? ['BCLOUD-21346', 'reactions_api_unavailable'] + : null; + if (unsupported) { + value.support = 'unsupported'; + value.explanation = unsupported[1]; + value.evidenceUrl = `https://jira.atlassian.com/browse/${unsupported[0]}`; + value.recovery = 'openProvider'; + } + return [action, value]; + }) + ) + ); +} +async function branchPolicy( + loaded: Loaded, + reported: ReviewOverview['checks'], + capability: ReviewCapability, + deleteCapability: ReviewCapability +) { + const { auth, review } = loaded; + // Destination restrictions cannot establish permission to delete a fork source. + const sourceBranch = + loaded.source?.repositoryId === auth.repository.repositoryId && + loaded.source?.workspaceUuid === auth.repository.workspaceUuid + ? review.source.branch?.name + : undefined; + const deletionRestrictions: string[] = []; + let methods: ReviewOverview['merge']['methods'] = []; + try { + const response = await auth.client.execute({ + operation: 'branch', + params: { path: { ...auth.path, name: review.destination.branch.name } }, + }); + if (response.status !== 200) throw new BitbucketInteractiveClientError('invalid_response'); + const branch = parseBitbucket( + z.object({ name: z.string(), merge_strategies: z.array(z.string().min(1)).optional() }), + response.data + ); + if (branch.name !== review.destination.branch.name) + throw new BitbucketInteractiveClientError('repository_mismatch'); + methods = (branch.merge_strategies ?? []).map(id => ({ id, label: id })); + } catch (error) { + if ( + !(error instanceof BitbucketInteractiveClientError) || + !['not_found', 'insufficient_permissions'].includes(error.code) + ) + throw error; + } + if (!methods.length) capability.restrictions.push('merge_strategies_unavailable'); + try { + const rules = await collect( + auth, + { operation: 'restrictions', params: { path: auth.path } }, + restrictionSchema, + scope(auth, 'checks', 'merge-restrictions', loaded.identity, loaded.revision) + ); + const matching = rules.filter(rule => { + // Only push and restrict_merges rules permit user/group exceptions, never delete rules. + const restrictsSource = + rule.kind === 'delete' || + (rule.kind === 'push' && !rule.users?.some(user => user.uuid === auth.actor.id)); + if (rule.branch_match_kind === 'branching_model') { + if (sourceBranch && restrictsSource) + deletionRestrictions.push('branching_model_restrictions_unknown'); + if ( + rule.kind === 'restrict_merges' || + rule.kind === 'enforce_merge_checks' || + rule.kind.startsWith('require_') + ) + capability.restrictions.push('branching_model_restrictions_unknown'); + return false; + } + if (rule.pattern === undefined) throw new BitbucketInteractiveClientError('invalid_response'); + // Bitbucket glob restrictions recognize '*' as the wildcard, including branch separators. + const pattern = rule.pattern + .split('*') + .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('.*'); + const matcher = new RegExp(`^${pattern}$`); + if (sourceBranch && restrictsSource && matcher.test(sourceBranch)) + deletionRestrictions.push( + rule.kind === 'delete' + ? 'source_branch_protected' + : rule.groups?.length + ? 'delete_group_membership_unknown' + : 'actor_delete_restricted' + ); + return matcher.test(review.destination.branch.name); + }); + const enforced = matching.some(rule => rule.kind === 'enforce_merge_checks'); + const policies = matching.filter(rule => rule.kind.startsWith('require_')); + if (policies.length) + capability.explanation = [ + capability.explanation, + enforced ? 'enforced_merge_checks' : 'advisory_merge_checks', + ...policies.map(rule => `${rule.kind}:${rule.value ?? ''}`), + ] + .filter(Boolean) + .join(';'); + for (const rule of matching) { + if (rule.kind === 'restrict_merges' && !rule.users?.some(user => user.uuid === auth.actor.id)) + capability.restrictions.push( + rule.groups?.length ? 'merge_group_membership_unknown' : 'actor_merge_restricted' + ); + if (!enforced || !rule.kind.startsWith('require_')) continue; + if ( + rule.kind === 'require_approvals_to_merge' && + rule.value !== undefined && + review.participants + ) { + if ( + review.participants.filter(value => value.state === 'approved' || value.approved).length < + rule.value + ) + capability.restrictions.push('approvals_required'); + } else if (rule.kind === 'require_no_changes_requested') { + if (review.participants.some(value => value.state === 'changes_requested')) + capability.restrictions.push('changes_requested'); + } else if (rule.kind === 'require_tasks_to_be_completed' && review.task_count !== undefined) { + if (review.task_count > 0) capability.restrictions.push('open_tasks'); + } else if (rule.kind === 'require_passing_builds_to_merge' && rule.value !== undefined) { + if ( + reported.status !== 'reported' || + reported.checks.filter(value => value.state === 'passed').length < rule.value || + reported.checks.some(value => value.state !== 'passed') + ) + capability.restrictions.push('passing_builds_required'); + } else capability.restrictions.push(`${rule.kind}:status_unknown`); + } + } catch (error) { + if ( + !(error instanceof BitbucketInteractiveClientError) || + !['not_found', 'insufficient_permissions'].includes(error.code) + ) + throw error; + capability.restrictions.push('merge_restrictions_unavailable'); + if (sourceBranch) deletionRestrictions.push('delete_restrictions_unavailable'); + } + if (deletionRestrictions.length) { + deleteCapability.restrictions.push(...deletionRestrictions); + deleteCapability.evidenceUrl = + 'https://developer.atlassian.com/cloud/bitbucket/rest/api-group-branch-restrictions/'; + if (deleteCapability.permission === 'allowed') { + deleteCapability.explanation = [deleteCapability.explanation, ...deletionRestrictions] + .filter(Boolean) + .join(';'); + deleteCapability.recovery = 'openProvider'; + } + } + return methods; +} +export async function getBitbucketReview( + auth: BitbucketReviewAuthorization, + number: string +): Promise { + const loaded = await load(auth, number); + const stats = await collect( + auth, + diffRequest(loaded), + diffstatSchema, + scope(auth, 'files', 'counts', loaded.identity, loaded.revision) + ); + const files = stats.map(stat => fileFromStat(loaded, stat)); + const commits = await collect( + auth, + { operation: 'commits', params: { path: loaded.path } }, + z.object({ hash: sha }), + scope(auth, 'files', 'commits', loaded.identity, loaded.revision) + ); + const reported = await checks(loaded); + const capabilities = capabilitySet(loaded); + const methods = await branchPolicy( + loaded, + reported, + capabilities.merge, + capabilities.deleteBranch + ); + await unchanged(loaded); + const { identity, title, author, state, draft } = loaded.summary; + const total = (field: 'additions' | 'deletions') => + files.reduce( + (sum, file) => (sum === null || file[field] === null ? null : sum + file[field]), + 0 + ); + return bounded({ + identity, + title, + author, + state, + draft, + bodyMarkdown: loaded.review.description ?? loaded.review.summary?.raw ?? null, + revision: loaded.revision, + source: { repository: loaded.source, branch: loaded.review.source.branch?.name ?? null }, + target: { repository: auth.repository, branch: loaded.review.destination.branch.name }, + authorization: { + actor: auth.actor, + credentialKind: auth.credentialKind, + capabilities, + writeLimits: { requestMaxBytes: REVIEW_WRITE_REQUEST_MAX_BYTES, bodyMaxBytes: null }, + }, + providerState: { + provider: 'bitbucket', + expectedHeadProtection: 'none', + participants: (loaded.review.participants ?? []).map(value => ({ + actor: bitbucketActor(value.user), + role: value.role, + state: value.state ?? (value.approved ? 'approved' : null), + participatedOn: value.participated_on ?? null, + })), + }, + checks: reported, + counts: { + commits: commits.length, + files: files.length, + additions: total('additions'), + deletions: total('deletions'), + }, + merge: { methods, squash: null, autoMerge: null, task: null }, + }); +} +export async function listBitbucketDiscussions( + auth: BitbucketReviewAuthorization, + identity: ReviewIdentity, + cursor?: ReviewCursor | null +): Promise> { + const loaded = await exact(auth, identity); + const bound = scope( + auth, + 'threads', + JSON.stringify([ + 'complete-threads', + loaded.source, + loaded.review.source.branch, + loaded.review.destination.branch, + ]), + identity, + loaded.revision + ); + let offset = 0; + if (cursor) { + try { + offset = z + .number() + .int() + .positive() + .max(5000) + .parse(Number(parseReviewCursor(cursor, bound).token)); + } catch { + throw new BitbucketInteractiveClientError('invalid_pagination'); + } + } + // Bitbucket paginates flat comments, not threads. Complete the bounded set before grouping replies. + const comments = await collect( + auth, + { operation: 'comments', params: { path: loaded.path } }, + commentSchema, + bound + ); + const byId = new Map(comments.map(comment => [comment.id, comment])); + if (byId.size !== comments.length) throw new BitbucketInteractiveClientError('invalid_response'); + const grouped = new Map(); + for (const comment of comments) { + if (comment.pullrequest && comment.pullrequest.id !== loaded.path.pull_request_id) + throw new BitbucketInteractiveClientError('repository_mismatch'); + let root = comment; + const visited = new Set(); + while (root.parent) { + if (visited.has(root.id)) throw new BitbucketInteractiveClientError('invalid_response'); + visited.add(root.id); + const parent = byId.get(root.parent.id); + if (!parent) throw new BitbucketInteractiveClientError('invalid_response'); + root = parent; + } + const thread = grouped.get(root.id) ?? []; + thread.push(comment); + grouped.set(root.id, thread); + } + const capabilities = capabilitySet(loaded); + const threads: ReviewThread[] = [...grouped].map(([rootId, replies]) => { + const root = byId.get(rootId); + if (!root) throw new BitbucketInteractiveClientError('invalid_response'); + const url = `${identity.canonicalUrl}/_/diff#comment-${rootId}`; + const available = root.deleted + ? { ...capabilities.resolveThread, restrictions: ['comment_deleted'] } + : capabilities.resolveThread; + return { + id: String(rootId), + reference: { provider: 'bitbucket', kind: 'thread', id: String(rootId), url }, + subjectType: root.inline + ? root.inline.from || root.inline.to + ? 'line' + : 'file' + : 'conversation', + // PR comments carry no immutable revision. A current PR snapshot cannot prove their original position. + file: null, + position: null, + diffHunk: null, + outdated: null, + resolved: root.resolution != null, + comments: { + items: [root, ...replies.filter(comment => comment.id !== rootId)].map(comment => ({ + id: String(comment.id), + reference: { + provider: 'bitbucket', + kind: 'comment', + id: String(comment.id), + url: `${identity.canonicalUrl}/_/diff#comment-${comment.id}`, + }, + author: comment.deleted || !comment.user ? null : bitbucketActor(comment.user), + bodyMarkdown: comment.deleted ? '' : (comment.content?.raw ?? ''), + createdAt: comment.created_on, + reactions: [], + })), + nextCursor: null, + }, + capabilities: { + resolveThread: available, + reopenThread: available, + addReaction: capabilities.addReaction, + removeReaction: capabilities.removeReaction, + }, + }; + }); + if (offset > threads.length || offset % 25 !== 0) + throw new BitbucketInteractiveClientError('invalid_pagination'); + await unchanged(loaded); + return bounded({ + items: threads.slice(offset, offset + 25), + nextCursor: + offset + 25 < threads.length + ? { scopeKey: reviewPageKey(bound), token: String(offset + 25) } + : null, + }); +}