diff --git a/apps/web/src/lib/cloud-agent/provider-branch-listing.test.ts b/apps/web/src/lib/cloud-agent/provider-branch-listing.test.ts new file mode 100644 index 0000000000..4f1743cbcb --- /dev/null +++ b/apps/web/src/lib/cloud-agent/provider-branch-listing.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, beforeEach } from '@jest/globals'; +import type { PlatformIntegration } from '@kilocode/db/schema'; +import { listProviderRepositoryBranches } from './provider-branch-listing'; + +const mockGetIntegrationForOwner = jest.fn(); +const mockGetIntegrationsByOrganization = jest.fn(); +const mockGetValidGitLabToken = jest.fn(); +const mockFetchGitLabBranches = jest.fn(); +const mockListGitHubBranches = jest.fn(); + +jest.mock('@/lib/integrations/db/platform-integrations', () => ({ + getIntegrationForOwner: (...args: unknown[]) => mockGetIntegrationForOwner(...args), + getIntegrationsByOrganization: (...args: unknown[]) => mockGetIntegrationsByOrganization(...args), +})); + +jest.mock('@/lib/integrations/github-apps-service', () => ({ + listBranches: (...args: unknown[]) => mockListGitHubBranches(...args), +})); + +jest.mock('@/lib/integrations/gitlab-service', () => ({ + getValidGitLabToken: (...args: unknown[]) => mockGetValidGitLabToken(...args), +})); + +jest.mock('@/lib/integrations/platforms/gitlab/adapter', () => ({ + fetchGitLabBranches: (...args: unknown[]) => mockFetchGitLabBranches(...args), +})); + +jest.mock('@/lib/utils.server', () => ({ + logExceptInTest: () => {}, + warnExceptInTest: () => {}, +})); + +const integrationRow = { + id: 'intg_1', + platform: 'gitlab', + integration_status: 'active', + owned_by_user_id: 'user_1', + owned_by_organization_id: null, + metadata: { gitlab_instance_url: 'https://gitlab.example.com' }, + repositories: [{ id: 7, name: 'repo', full_name: 'group/repo', private: true }], +} as unknown as PlatformIntegration; + +beforeEach(() => { + jest.clearAllMocks(); + mockGetIntegrationForOwner.mockResolvedValue(integrationRow); + mockGetValidGitLabToken.mockResolvedValue('glpat-mock-token'); + mockFetchGitLabBranches.mockResolvedValue([ + { name: 'main', default: true, protected: true }, + { name: 'feature/deploy', default: false, protected: false }, + ]); +}); + +describe('listProviderRepositoryBranches (gitlab)', () => { + it('authorizes the project against the integration repository cache before listing', async () => { + const listing = await listProviderRepositoryBranches({ + platform: 'gitlab', + userId: 'user_1', + repositoryFullName: 'group/repo', + }); + + expect(listing).toEqual({ + defaultBranch: 'main', + branches: ['main', 'feature/deploy'], + }); + expect(mockFetchGitLabBranches).toHaveBeenCalledWith( + 'glpat-mock-token', + 'group/repo', + 'https://gitlab.example.com' + ); + }); + + it('refuses a project outside the connected repositories before any provider call', async () => { + await expect( + listProviderRepositoryBranches({ + platform: 'gitlab', + userId: 'user_1', + repositoryFullName: 'other/project', + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + + expect(mockGetValidGitLabToken).not.toHaveBeenCalled(); + expect(mockFetchGitLabBranches).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/cloud-agent/provider-branch-listing.ts b/apps/web/src/lib/cloud-agent/provider-branch-listing.ts new file mode 100644 index 0000000000..75c090e11c --- /dev/null +++ b/apps/web/src/lib/cloud-agent/provider-branch-listing.ts @@ -0,0 +1,383 @@ +/** + * Provider branch listing for the new-session flow. + * + * One entry point per repository identity, three providers: GitHub via + * `githubAppsService.listBranches`, GitLab via + * `gitlabService.listGitLabBranches`, Bitbucket Cloud via the s3 review + * layer's authorized requests (`/refs/branches` + the repository object's + * `mainbranch`). The + * server resolves the integration and the credentials itself — no caller + * supplies an integration id, a token, or a host, and the repository + * identity is re-checked against the integration's own cache on every call. + * + * Bitbucket Cloud is organization-context only: a personal call returns the + * explicit org-only unavailable state (FORBIDDEN with the shared copy from + * the authorization layer), never an empty success. + */ +import 'server-only'; + +import * as z from 'zod'; +import { TRPCError } from '@trpc/server'; + +import { INTEGRATION_STATUS, PLATFORM } from '@/lib/integrations/core/constants'; +import { isPlatformIntegrationHealthy } from '@/lib/integrations/core/health'; +import type { Owner } from '@/lib/integrations/core/types'; +import { + getIntegrationForOwner, + getIntegrationsByOrganization, +} from '@/lib/integrations/db/platform-integrations'; +import * as githubAppsService from '@/lib/integrations/github-apps-service'; +import { fetchGitLabBranches } from '@/lib/integrations/platforms/gitlab/adapter'; +import { + authorizeProject, + GitLabReviewError, + type GitLabReviewOwner, +} from '@/lib/provider-review/gitlab-authorization'; +import { + authorizeRepository, + BITBUCKET_ORGANIZATION_ONLY_MESSAGE, + BitbucketReviewError, + type BitbucketRepositoryAccess, +} from '@/lib/provider-review/bitbucket-authorization'; +import { + fetchPage, + repositoryPathGuard, + requestBitbucketJson, +} from '@/lib/provider-review/bitbucket-read'; + +export type ProviderBranchPlatform = 'github' | 'gitlab' | 'bitbucket'; + +export type ProviderBranchListing = { + /** The provider's default branch, or null when the provider reports none. */ + defaultBranch: string | null; + branches: string[]; +}; + +/** The router output contract: `{ defaultBranch, branches }`, nothing else. */ +export const ProviderBranchListingSchema = z + .object({ + defaultBranch: z.string().nullable(), + branches: z.array(z.string()), + }) + .strict(); + +/** + * `owner/repo`, `group/sub/project`, or `workspace/slug` — a path with at + * least one separator, bounded like the review router's project paths. + */ +export const repositoryFullNameSchema = z + .string() + .regex(/^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+$/) + .max(1024); + +/** The two connection refusals, shared so every provider path words them alike. */ +const missingConnectionMessage = (label: string) => + `No ${label} connection found for this account. Connect ${label} first.`; +const inactiveConnectionMessage = (label: string) => + `The ${label} connection is no longer active. Reconnect ${label} to continue.`; + +/** The active integration row of one owner and platform, or a clear refusal. */ +async function requireActiveIntegration(owner: Owner, platform: string, label: string) { + const integration = await getIntegrationForOwner(owner, platform); + if (!integration) { + throw new TRPCError({ code: 'NOT_FOUND', message: missingConnectionMessage(label) }); + } + if (integration.integration_status !== INTEGRATION_STATUS.ACTIVE) { + throw new TRPCError({ code: 'NOT_FOUND', message: inactiveConnectionMessage(label) }); + } + return integration; +} + +/** + * Map a classified Bitbucket refusal onto the router error states. The + * message is the authorization layer's fixed copy — it never embeds a token + * or a workspace identity. + */ +function bitbucketErrorToTrpcError(error: BitbucketReviewError): TRPCError { + switch (error.kind) { + case 'not_found': + return new TRPCError({ code: 'NOT_FOUND', message: error.message }); + case 'forbidden': + return new TRPCError({ code: 'FORBIDDEN', message: error.message }); + case 'bad_request': + return new TRPCError({ code: 'BAD_REQUEST', message: error.message }); + default: + // stale_head cannot come from a read; retryable surfaces as a + // retryable gateway failure, never as an empty branch list. + return new TRPCError({ code: 'BAD_GATEWAY', message: error.message }); + } +} + +type GitHubBranchRef = { name: string; isDefault: boolean }; + +function toListing(branches: GitHubBranchRef[]): ProviderBranchListing { + return { + defaultBranch: branches.find(branch => branch.isDefault)?.name ?? null, + branches: branches.map(branch => branch.name), + }; +} + +type GitHubIntegrationRow = Awaited>[number]; + +/** Does this installation's repository cache list the repository? GitHub paths are case-insensitive. */ +function cachesRepository(integration: GitHubIntegrationRow, repositoryFullName: string): boolean { + const wanted = repositoryFullName.toLowerCase(); + return (integration.repositories ?? []).some( + repository => repository.full_name?.toLowerCase() === wanted + ); +} + +/** A refusal meaning "this installation cannot see that repository" — try the next one. */ +function isRepositoryUnreachable(error: unknown): boolean { + if (error instanceof TRPCError) return error.code === 'NOT_FOUND' || error.code === 'FORBIDDEN'; + const status = (error as { status?: unknown } | null)?.status; + return status === 404 || status === 403; +} + +/** + * An organization can hold several GitHub installations, one per GitHub + * account it connected. The primary (oldest healthy) row only sees its own + * repositories, so the installation is resolved from the REPOSITORY: the one + * whose repository cache lists it goes first, then the remaining healthy rows, + * so a stale cache cannot hide a repository an installation can really see. + * The caller still supplies no integration id — every candidate is an + * organization-owned row, and `listBranches` re-checks that ownership. + */ +async function listOrganizationGitHubBranches( + owner: Owner, + organizationId: string, + repositoryFullName: string +): Promise { + const integrations = await getIntegrationsByOrganization(organizationId, PLATFORM.GITHUB); + const healthy = integrations.filter(isPlatformIntegrationHealthy); + if (healthy.length === 0) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: integrations.length + ? inactiveConnectionMessage('GitHub') + : missingConnectionMessage('GitHub'), + }); + } + + const candidates = [ + ...healthy.filter(integration => cachesRepository(integration, repositoryFullName)), + ...healthy.filter(integration => !cachesRepository(integration, repositoryFullName)), + ]; + let lastError: unknown; + for (const integration of candidates) { + try { + const { branches } = await githubAppsService.listBranches( + owner, + integration.id, + repositoryFullName + ); + return toListing(branches); + } catch (error) { + if (!isRepositoryUnreachable(error)) throw error; + lastError = error; + } + } + throw new TRPCError({ + code: 'NOT_FOUND', + message: + 'This repository is not available in any connected GitHub installation. Install the GitHub App on the account that owns it.', + cause: lastError, + }); +} + +async function listGitHubBranches( + owner: Owner, + repositoryFullName: string +): Promise { + if (owner.type === 'org') { + return listOrganizationGitHubBranches(owner, owner.id, repositoryFullName); + } + const integration = await requireActiveIntegration(owner, PLATFORM.GITHUB, 'GitHub'); + const { branches } = await githubAppsService.listBranches( + owner, + integration.id, + repositoryFullName + ); + return toListing(branches); +} + +/** A refusal meaning "this GitLab review call cannot proceed" — router worded. */ +function gitlabErrorToTrpcError(error: GitLabReviewError): TRPCError { + switch (error.kind) { + case 'not_found': + return new TRPCError({ code: 'NOT_FOUND', message: error.message }); + case 'forbidden': + return new TRPCError({ code: 'FORBIDDEN', message: error.message }); + case 'bad_request': + return new TRPCError({ code: 'BAD_REQUEST', message: error.message }); + default: + // stale_head cannot come from a read; retryable surfaces as a + // retryable gateway failure, never as an empty branch list. + return new TRPCError({ code: 'BAD_GATEWAY', message: error.message }); + } +} + +async function listGitLabBranches( + owner: Owner, + actor: { userId: string; organizationId?: string }, + repositoryFullName: string +): Promise { + // authorizeProject is the review layer's GitLab authorization: the token + // and instance URL are server-derived, and the project must match the + // integration's repository cache (case-insensitive full path) — the same + // authorization boundary the Bitbucket path enforces through + // authorizeRepository. Without it, any project path the connected token + // can reach would be listable. + const reviewOwner: GitLabReviewOwner = + owner.type === 'org' + ? { type: 'organization', organizationId: owner.id, userId: actor.userId } + : { type: 'user', userId: actor.userId }; + try { + const access = await authorizeProject(reviewOwner, repositoryFullName); + const branches = await fetchGitLabBranches( + access.accessToken, + access.projectPath, + access.instanceUrl + ); + return toListing(branches.map(branch => ({ name: branch.name, isDefault: branch.default }))); + } catch (error) { + if (error instanceof GitLabReviewError) throw gitlabErrorToTrpcError(error); + throw error; + } +} + +/** + * The repository-metadata response: `GET /2.0/repositories/{ws}/{slug}` + * returns the repository object, whose `mainbranch` is the default branch. + * Bitbucket Cloud has no `/branch-model` endpoint — every other Bitbucket + * adapter here (bitbucket-api.ts, workspace-access-token-adapter.ts) takes + * the default branch from `mainbranch.name`. + */ +const BitbucketRepositoryMetadataSchema = z + .object({ + mainbranch: z + .object({ name: z.string().min(1) }) + .nullable() + .optional(), + }) + .passthrough(); + +const BitbucketBranchRefSchema = z + .object({ + name: z.string().min(1), + type: z.string().optional(), + }) + .passthrough(); + +/** Bound the page follow: a workspace with more branches than this is pathological. */ +const MAX_BRANCH_PAGES = 20; + +function repositoryApiPath(access: BitbucketRepositoryAccess): string { + return `/2.0/repositories/${encodeURIComponent(access.workspace.slug)}/${encodeURIComponent(access.repository.slug)}`; +} + +async function listBitbucketBranches( + input: { userId: string; organizationId?: string }, + repositoryFullName: string +): Promise { + if (!input.organizationId) { + // Explicit org-only unavailable state — never an empty success. The copy + // is the shared constant from the authorization layer, so the review + // surface and the branch listing refuse in the same words. + throw new TRPCError({ code: 'FORBIDDEN', message: BITBUCKET_ORGANIZATION_ONLY_MESSAGE }); + } + const separator = repositoryFullName.indexOf('/'); + const workspace = repositoryFullName.slice(0, separator); + const repoSlug = repositoryFullName.slice(separator + 1); + if (separator < 1 || repoSlug.length === 0) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'The Bitbucket repository must be named "workspace/repository".', + }); + } + + let access: BitbucketRepositoryAccess; + try { + // authorizeRepository re-derives the connected workspace identity from + // the integration and verifies the repository against its cache — a + // client cannot steer it to another workspace. + access = await authorizeRepository( + { type: 'organization', organizationId: input.organizationId, userId: input.userId }, + workspace, + repoSlug + ); + } catch (error) { + if (error instanceof BitbucketReviewError) throw bitbucketErrorToTrpcError(error); + throw error; + } + + let defaultBranch: string | null = null; + try { + const metadata = BitbucketRepositoryMetadataSchema.safeParse( + await requestBitbucketJson(access, repositoryApiPath(access)) + ); + defaultBranch = metadata.success ? (metadata.data.mainbranch?.name ?? null) : null; + } catch { + // A repository-metadata read failure must not blank the branch list; the + // session flow works without a preselected default. + } + + const names: string[] = []; + const seen = new Set(); + let cursor: string | undefined; + try { + for (let page = 0; page < MAX_BRANCH_PAGES; page += 1) { + const result = await fetchPage( + access, + `${repositoryApiPath(access)}/refs/branches`, + `bitbucket-branches:${access.repository.fullName}`, + cursor, + repositoryPathGuard(access) + ); + for (const value of result.values) { + const parsed = BitbucketBranchRefSchema.safeParse(value); + if (!parsed.success) continue; + if (parsed.data.type !== undefined && parsed.data.type !== 'branch') continue; + if (seen.has(parsed.data.name)) continue; + seen.add(parsed.data.name); + names.push(parsed.data.name); + } + if (!result.nextCursor || result.nextCursor === cursor) break; + cursor = result.nextCursor; + } + } catch (error) { + if (error instanceof BitbucketReviewError) throw bitbucketErrorToTrpcError(error); + throw error; + } + + return { defaultBranch, branches: names }; +} + +/** + * List the branches of one repository on one provider. The owner identity + * comes from the caller's context (the router passes `ctx.user.id` plus a + * guard-checked organizationId); the integration, token, and repository + * identity are re-derived here on every call. + */ +export async function listProviderRepositoryBranches(input: { + platform: ProviderBranchPlatform; + userId: string; + organizationId?: string; + repositoryFullName: string; +}): Promise { + const owner: Owner = input.organizationId + ? { type: 'org', id: input.organizationId } + : { type: 'user', id: input.userId }; + const actor = { + userId: input.userId, + ...(input.organizationId ? { organizationId: input.organizationId } : {}), + }; + switch (input.platform) { + case 'github': + return listGitHubBranches(owner, input.repositoryFullName); + case 'gitlab': + return listGitLabBranches(owner, actor, input.repositoryFullName); + case 'bitbucket': + return listBitbucketBranches(input, input.repositoryFullName); + } +} 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..3b8c50235d --- /dev/null +++ b/apps/web/src/lib/provider-review/bitbucket-authorization.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, it, beforeEach, afterEach } from '@jest/globals'; +import { TRPCError } from '@trpc/server'; +import { + authorizeRepository, + authorizeWorkspace, + BITBUCKET_WORKSPACE_ACCESS_TOKEN_AUDIENCE, + classifyBitbucketError, + classifyBitbucketStatus, + fetchBitbucketWorkspaceAccessToken, + BitbucketApiStatusError, + BitbucketReviewError, + type BitbucketReviewOwner, +} from './bitbucket-authorization'; + +const mockGetBitbucketWorkspaceAccessTokenStatus = jest.fn(); +const mockReadCachedRepositories = jest.fn(); + +jest.mock('@/lib/integrations/platforms/bitbucket/workspace-access-token-repository-cache', () => ({ + getBitbucketWorkspaceAccessTokenStatus: (...args: unknown[]) => + mockGetBitbucketWorkspaceAccessTokenStatus(...args), + readCachedBitbucketWorkspaceAccessTokenRepositories: (input: unknown) => + mockReadCachedRepositories(input), +})); + +jest.mock('@/lib/config.server', () => ({ + GIT_TOKEN_SERVICE_API_URL: 'https://token-service.example.com', +})); + +jest.mock('@/lib/tokens', () => ({ + generateInternalServiceToken: jest.fn(() => 'svc-mock-token'), + TOKEN_EXPIRY: { fiveMinutes: 300 }, +})); + +jest.mock('@/lib/utils.server', () => ({ + logExceptInTest: () => {}, + warnExceptInTest: () => {}, +})); + +const ORG_OWNER: BitbucketReviewOwner = { + type: 'organization', + organizationId: 'org_1', + userId: 'user_1', +}; +const USER_OWNER: BitbucketReviewOwner = { type: 'user', userId: 'user_1' }; + +const WORKSPACE = { uuid: '12345678-1234-1234-1234-123456789012', slug: 'acme' }; + +function connectedStatus() { + return { + status: 'connected', + integrationId: 'intg_1', + workspace: { ...WORKSPACE, displayName: 'Acme' }, + }; +} + +function cacheAvailable() { + return { + status: 'available', + repositories: [ + { + id: '87654321-4321-4321-4321-210987654321', + workspaceUuid: WORKSPACE.uuid, + name: 'repo', + fullName: 'acme/repo', + private: true, + defaultBranch: 'main', + }, + { + id: '11111111-2222-3333-4444-555555555555', + workspaceUuid: WORKSPACE.uuid, + name: 'other', + fullName: 'acme/other', + private: false, + }, + ], + syncedAt: '2026-09-06T00:00:00.000Z', + }; +} + +let fetchMock: jest.Mock; + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +/** Await a rejection and return it typed, without a success-branch union. */ +async function captureRejection(promise: Promise): Promise { + try { + await promise; + } catch (reason) { + return reason as BitbucketReviewError; + } + throw new Error('Expected the call to reject.'); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockGetBitbucketWorkspaceAccessTokenStatus.mockResolvedValue(connectedStatus()); + mockReadCachedRepositories.mockResolvedValue(cacheAvailable()); + fetchMock = jest.fn(); + fetchMock.mockImplementation(async () => + jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }) + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('organization-only ownership', () => { + it('refuses a user owner with a clear not-found naming the organization context', async () => { + const error = await captureRejection(authorizeWorkspace(USER_OWNER)); + + expect(error).toBeInstanceOf(BitbucketReviewError); + expect(error.kind).toBe('not_found'); + expect(error.retryable).toBe(false); + expect(error.message).toContain('organization'); + expect(mockGetBitbucketWorkspaceAccessTokenStatus).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refuses a user owner on authorizeRepository before any identity work', async () => { + const error = await captureRejection(authorizeRepository(USER_OWNER, 'acme', 'repo')); + + expect(error.kind).toBe('not_found'); + expect(mockGetBitbucketWorkspaceAccessTokenStatus).not.toHaveBeenCalled(); + expect(mockReadCachedRepositories).not.toHaveBeenCalled(); + }); +}); + +describe('authorizeWorkspace', () => { + it('resolves the org integration identity and returns the released token', async () => { + const access = await authorizeWorkspace(ORG_OWNER); + + expect(access.accessToken).toBe('at-mock-token'); + expect(access.workspace).toEqual(WORKSPACE); + expect(mockGetBitbucketWorkspaceAccessTokenStatus).toHaveBeenCalledWith('org_1'); + }); + + it('maps a missing connection to non-retryable not_found', async () => { + mockGetBitbucketWorkspaceAccessTokenStatus.mockResolvedValue({ status: 'not_connected' }); + + await expect(authorizeWorkspace(ORG_OWNER)).rejects.toMatchObject({ + kind: 'not_found', + retryable: false, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('maps a degraded connection to non-retryable not_found', async () => { + mockGetBitbucketWorkspaceAccessTokenStatus.mockResolvedValue({ + status: 'reconnect_required', + workspace: null, + integrationId: null, + }); + + await expect(authorizeWorkspace(ORG_OWNER)).rejects.toMatchObject({ kind: 'not_found' }); + }); +}); + +describe('fetchBitbucketWorkspaceAccessToken — release contract', () => { + const releaseInput = { + userId: 'user_1', + organizationId: 'org_1', + integrationId: 'intg_1', + expectedWorkspace: WORKSPACE, + }; + + it('mints an internal service token for the workspace-access-token audience', async () => { + await fetchBitbucketWorkspaceAccessToken(releaseInput); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://token-service.example.com/internal/bitbucket/workspace-access-token'); + expect(init.method).toBe('POST'); + expect(init.headers.Authorization).toBe('Bearer svc-mock-token'); + expect(JSON.parse(init.body)).toEqual({ + integrationId: 'intg_1', + workspaceUuid: WORKSPACE.uuid, + workspaceSlug: WORKSPACE.slug, + }); + }); + + it('degrades a transport failure to retryable temporarily_unavailable', async () => { + fetchMock.mockImplementation(async () => { + throw new TypeError('fetch failed'); + }); + + const result = await fetchBitbucketWorkspaceAccessToken(releaseInput); + + expect(result.status).toBe('temporarily_unavailable'); + }); + + it('degrades a non-JSON release response to temporarily_unavailable', async () => { + fetchMock.mockImplementation( + async () => new Response('', { status: 200, headers: { 'content-type': 'text/html' } }) + ); + + const result = await fetchBitbucketWorkspaceAccessToken(releaseInput); + + expect(result.status).toBe('temporarily_unavailable'); + }); + + it('degrades a non-2xx release response to temporarily_unavailable', async () => { + fetchMock.mockImplementation(async () => jsonResponse({ error: 'unauthorized' }, 401)); + + const result = await fetchBitbucketWorkspaceAccessToken(releaseInput); + + expect(result.status).toBe('temporarily_unavailable'); + }); + + it('refuses a released token whose workspace echo does not match the request', async () => { + fetchMock.mockImplementation(async () => + jsonResponse({ + status: 'available', + token: 'at-mock-token', + workspace: { uuid: '99999999-9999-9999-9999-999999999999', slug: 'other' }, + }) + ); + + const result = await fetchBitbucketWorkspaceAccessToken(releaseInput); + + expect(result.status).toBe('reconnect_required'); + }); + + it('passes a structured not_connected through to the caller', async () => { + fetchMock.mockImplementation(async () => jsonResponse({ status: 'not_connected' })); + + const result = await fetchBitbucketWorkspaceAccessToken(releaseInput); + + expect(result.status).toBe('not_connected'); + }); + + it('exposes the operation-specific audience for the release endpoint', () => { + expect(BITBUCKET_WORKSPACE_ACCESS_TOKEN_AUDIENCE).toBe( + 'git-token-service:bitbucket-workspace-access-token' + ); + }); +}); + +describe('authorizeRepository — identity resolution', () => { + it('resolves workspace and repository identity from the integration cache', async () => { + const access = await authorizeRepository(ORG_OWNER, 'acme', 'repo'); + + expect(access.workspace).toEqual(WORKSPACE); + expect(access.repository).toMatchObject({ + uuid: '87654321-4321-4321-4321-210987654321', + slug: 'repo', + fullName: 'acme/repo', + }); + expect(access.accessToken).toBe('at-mock-token'); + expect(mockReadCachedRepositories).toHaveBeenCalledWith({ + organizationId: 'org_1', + expectedIntegrationId: 'intg_1', + }); + }); + + it('matches the requested repository case-insensitively and returns the canonical slug', async () => { + const access = await authorizeRepository(ORG_OWNER, 'ACME', 'Repo'); + + expect(access.repository.slug).toBe('repo'); + expect(access.repository.fullName).toBe('acme/repo'); + }); + + it('releases the token only after the repository identity resolves', async () => { + mockReadCachedRepositories.mockResolvedValue(cacheAvailable()); + + await expect(authorizeRepository(ORG_OWNER, 'acme', 'missing')).rejects.toMatchObject({ + kind: 'not_found', + retryable: false, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refuses a repository outside the connected workspace as not_found', async () => { + await expect(authorizeRepository(ORG_OWNER, 'other-workspace', 'repo')).rejects.toMatchObject({ + kind: 'not_found', + }); + expect(mockReadCachedRepositories).not.toHaveBeenCalled(); + }); + + it('refuses a repository slug carrying a path segment', async () => { + await expect(authorizeRepository(ORG_OWNER, 'acme', 'repo/pull')).rejects.toMatchObject({ + kind: 'not_found', + }); + expect(mockReadCachedRepositories).not.toHaveBeenCalled(); + }); + + it('maps an insufficient-permission repository cache to non-retryable forbidden', async () => { + mockReadCachedRepositories.mockResolvedValue({ status: 'insufficient_permissions' }); + + const error = await captureRejection(authorizeRepository(ORG_OWNER, 'acme', 'repo')); + + expect(error).toBeInstanceOf(BitbucketReviewError); + expect(error.kind).toBe('forbidden'); + expect(error.retryable).toBe(false); + expect(error.message).toMatch(/reconnect/i); + expect(error.message).toMatch(/scope/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('maps an unavailable repository cache to retryable', async () => { + mockReadCachedRepositories.mockResolvedValue({ status: 'temporarily_unavailable' }); + + await expect(authorizeRepository(ORG_OWNER, 'acme', 'repo')).rejects.toMatchObject({ + kind: 'retryable', + retryable: true, + }); + }); +}); + +describe('credential failure classification', () => { + it('maps release failures onto the mobile error states', async () => { + fetchMock.mockImplementation(async () => jsonResponse({ status: 'not_connected' })); + + await expect(authorizeWorkspace(ORG_OWNER)).rejects.toMatchObject({ + kind: 'not_found', + }); + + fetchMock.mockImplementation(async () => jsonResponse({ status: 'invalid_request' })); + + await expect(authorizeWorkspace(ORG_OWNER)).rejects.toMatchObject({ + kind: 'bad_request', + }); + + fetchMock.mockImplementation(async () => jsonResponse({ status: 'temporarily_unavailable' })); + + await expect(authorizeWorkspace(ORG_OWNER)).rejects.toMatchObject({ + kind: 'retryable', + retryable: true, + }); + }); + + it('classifies provider statuses onto non-retryable and retryable kinds', () => { + expect(classifyBitbucketStatus(404).kind).toBe('not_found'); + expect(classifyBitbucketStatus(403).kind).toBe('forbidden'); + expect(classifyBitbucketStatus(409).kind).toBe('stale_head'); + expect(classifyBitbucketStatus(409).retryable).toBe(false); + expect(classifyBitbucketStatus(502).retryable).toBe(true); + expect(classifyBitbucketStatus(429).kind).toBe('retryable'); + }); + + it('extracts the status from transport error message tails', () => { + const classified = classifyBitbucketError(new Error('Bitbucket GET request failed: 404')); + expect(classified.kind).toBe('not_found'); + }); + + it('never echoes provider bodies into the classified message', () => { + const leaked = new BitbucketApiStatusError( + 403, + 'Bitbucket POST request failed: 403 {"error":"token at-secret for workspace acme denied"}' + ); + const classified = classifyBitbucketError(leaked); + expect(classified.kind).toBe('forbidden'); + expect(classified.message).not.toContain('at-secret'); + }); + + it('classifies network failures as retryable', () => { + expect(classifyBitbucketError(new TypeError('fetch failed')).retryable).toBe(true); + }); + + it('maps broker TRPC errors onto the review error kinds', async () => { + mockReadCachedRepositories.mockResolvedValue(cacheAvailable()); + fetchMock.mockImplementation(async () => { + throw new TRPCError({ code: 'SERVICE_UNAVAILABLE' }); + }); + + // The release client itself degrades to temporarily_unavailable, which the + // workspace layer maps to a retryable review error. + await expect(authorizeWorkspace(ORG_OWNER)).rejects.toMatchObject({ kind: 'retryable' }); + }); +}); 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..363c97cc12 --- /dev/null +++ b/apps/web/src/lib/provider-review/bitbucket-authorization.ts @@ -0,0 +1,518 @@ +/** + * Server-derived Bitbucket Cloud credentials for the PR review layer. + * + * Bitbucket Cloud exists in an organization context only — there is no + * personal Bitbucket integration. A caller supplies an owner, a workspace + * slug, and a repository slug; the workspace identity (UUID + slug), the + * repository identity (UUID + full name), and the workspace access token are + * resolved here and only here. The workspace access token never leaves the + * web process in plaintext form: it is brokered from the git-token-service, + * which holds the private credential key, so a caller can never re-target the + * layer at another workspace by pasting an identity. + */ +import 'server-only'; + +import { z } from 'zod'; +import { TRPCError } from '@trpc/server'; +import { BITBUCKET_WORKSPACE_ACCESS_TOKEN_AUDIENCE } from '@kilocode/worker-utils/internal-service-token-audiences'; +import { GIT_TOKEN_SERVICE_API_URL } from '@/lib/config.server'; +import { generateInternalServiceToken, TOKEN_EXPIRY } from '@/lib/tokens'; +import { + getBitbucketWorkspaceAccessTokenStatus, + readCachedBitbucketWorkspaceAccessTokenRepositories, +} from '@/lib/integrations/platforms/bitbucket/workspace-access-token-repository-cache'; +import { logExceptInTest } from '@/lib/utils.server'; + +/** + * The account that owns the review context. Bitbucket Cloud is supported in + * organization context only, so a user owner is refused with a clear + * not-found error that names the organization requirement. + */ +export type BitbucketReviewOwner = + | { type: 'user'; userId: string } + | { type: 'organization'; organizationId: string; userId: string }; + +/** + * The explicit org-only unavailable state, shared by every refusal site (this + * layer, the branch listing, and the routers) so the copy never drifts. + */ +export const BITBUCKET_ORGANIZATION_ONLY_MESSAGE = + 'Bitbucket pull requests are available in an organization context only. Switch to an organization with a connected Bitbucket workspace.'; + +export type BitbucketReviewErrorKind = + | 'not_found' + | 'forbidden' + | 'stale_head' + | 'bad_request' + | 'retryable'; + +/** + * A classified provider failure. `retryable` is true only for 5xx/network + * outcomes. The message is fixed copy and never embeds a token or a + * workspace identity, so every output of this layer is safe to show or log. + */ +export class BitbucketReviewError extends Error { + readonly kind: BitbucketReviewErrorKind; + readonly retryable: boolean; + + constructor(kind: BitbucketReviewErrorKind, message: string) { + super(message); + this.name = 'BitbucketReviewError'; + this.kind = kind; + this.retryable = kind === 'retryable'; + } +} + +/** An HTTP failure raised by this layer's own Bitbucket JSON requests. */ +export class BitbucketApiStatusError extends Error { + constructor( + readonly status: number, + message: string + ) { + super(message); + this.name = 'BitbucketApiStatusError'; + } +} + +/** + * Bitbucket status → kind: 404 not_found, 401/403 forbidden, 409 stale head, + * 400/405/422 bad request, 5xx/429 retryable. + */ +export function classifyBitbucketStatus(status: number): BitbucketReviewError { + if (status === 404) { + return new BitbucketReviewError( + 'not_found', + 'The Bitbucket pull request or repository was not found, or you do not have access to it.' + ); + } + if (status === 401 || status === 403) { + return new BitbucketReviewError( + 'forbidden', + 'Your Bitbucket workspace access does not allow this action on this pull request.' + ); + } + if (status === 409) { + return new BitbucketReviewError( + 'stale_head', + 'The pull request changed since it was loaded. Reload the pull request and try again.' + ); + } + if (status === 400 || status === 405 || status === 422) { + return new BitbucketReviewError('bad_request', 'Bitbucket rejected this request.'); + } + if (status === 429 || status >= 500) { + return new BitbucketReviewError( + 'retryable', + 'Bitbucket is temporarily unavailable. Try again.' + ); + } + return new BitbucketReviewError('retryable', 'Bitbucket returned an unexpected error.'); +} + +function isNetworkFailure(error: Error): boolean { + return ( + error.name === 'TypeError' || + error.name === 'TimeoutError' || + error.name === 'AbortError' || + error.message.toLowerCase().includes('fetch failed') + ); +} + +function classifyTrpcError(code: TRPCError['code']): BitbucketReviewError { + switch (code) { + case 'NOT_FOUND': + return new BitbucketReviewError('not_found', 'Bitbucket integration not found.'); + case 'UNAUTHORIZED': + return new BitbucketReviewError('forbidden', 'Your Bitbucket connection is no longer valid.'); + case 'SERVICE_UNAVAILABLE': + return new BitbucketReviewError( + 'retryable', + 'Bitbucket credentials are temporarily unavailable.' + ); + default: + return new BitbucketReviewError('retryable', 'Could not resolve your Bitbucket credentials.'); + } +} + +/** + * Map one provider failure onto the mobile error states. The fixed-copy + * contract matches gitlab-authorization: only the status code survives from a + * provider failure, so no response body — and no token — can leak into the + * classified message. + */ +export function classifyBitbucketError(error: unknown): BitbucketReviewError { + if (error instanceof BitbucketReviewError) return error; + if (error instanceof TRPCError) { + return classifyTrpcError(error.code); + } + if (error instanceof BitbucketApiStatusError) { + return classifyBitbucketStatus(error.status); + } + if (error instanceof Error) { + const status = error.message.match(/:\s*(\d{3})\b/); + if (status?.[1]) { + return classifyBitbucketStatus(Number(status[1])); + } + if (isNetworkFailure(error)) { + return new BitbucketReviewError('retryable', 'Could not reach Bitbucket. Please try again.'); + } + } + logExceptInTest('[bitbucket-authorization] Unclassified Bitbucket failure:', error); + return new BitbucketReviewError('retryable', 'Bitbucket returned an unexpected error.'); +} + +/** The connected workspace identity of the organization integration. */ +export type BitbucketWorkspace = { uuid: string; slug: string }; + +export type BitbucketRepositoryIdentity = { + /** The provider repository UUID from the integration's repository cache. */ + uuid: string; + slug: string; + fullName: string; +}; + +/** The credentials and canonical workspace identity one workspace request may use. */ +export type BitbucketWorkspaceAccess = { + accessToken: string; + workspace: BitbucketWorkspace; + owner: BitbucketReviewOwner; +}; + +/** The credentials and resolved repository identity one repository request may use. */ +export type BitbucketRepositoryAccess = BitbucketWorkspaceAccess & { + repository: BitbucketRepositoryIdentity; +}; + +/** + * The audience the review layer mints its internal service token for when it + * asks the git-token-service to release the workspace access token. The + * git-token-service holds the private credential key — the web process stores + * the token only as a public-key envelope — so the release endpoint is the + * only path that can hand a usable Bitbucket token to this layer. The + * endpoint (POST {GIT_TOKEN_SERVICE_API_URL}/internal/bitbucket/workspace-access-token) + * mirrors the GitLab credential broker: it verifies this operation-specific + * audience, re-resolves the integration for the org, decrypts the credential, + * and re-checks the workspace identity before answering. The audience string + * lives in @kilocode/worker-utils/internal-service-token-audiences next to the + * endpoint so both sides import one constant. + */ +export { BITBUCKET_WORKSPACE_ACCESS_TOKEN_AUDIENCE }; + +const BITBUCKET_WORKSPACE_ACCESS_TOKEN_RELEASE_PATH = '/internal/bitbucket/workspace-access-token'; +const BITBUCKET_WORKSPACE_ACCESS_TOKEN_RESPONSE_MAX_BYTES = 16_384; +const BITBUCKET_WORKSPACE_ACCESS_TOKEN_REQUEST_TIMEOUT_MS = 30_000; + +const BitbucketWorkspaceAccessTokenReleaseResultSchema = z.discriminatedUnion('status', [ + z + .object({ + status: z.literal('available'), + token: z.string().min(1).max(8_192), + workspace: z.object({ uuid: z.string().min(1), slug: z.string().min(1) }).strict(), + }) + .strict(), + z.object({ status: z.literal('invalid_request') }).strict(), + z.object({ status: z.literal('not_connected') }).strict(), + z.object({ status: z.literal('reconnect_required') }).strict(), + z.object({ status: z.literal('temporarily_unavailable') }).strict(), +]); + +export type BitbucketWorkspaceAccessTokenReleaseResult = z.infer< + typeof BitbucketWorkspaceAccessTokenReleaseResultSchema +>; + +async function readBoundedReleaseJson(response: Response): Promise { + if (!response.body) throw new Error('invalid_response'); + const contentType = response.headers.get('Content-Type')?.split(';', 1)[0].trim().toLowerCase(); + if (contentType !== 'application/json') throw new Error('invalid_response'); + const contentLength = response.headers.get('Content-Length'); + if ( + contentLength && + (!/^[0-9]+$/.test(contentLength) || + Number(contentLength) > BITBUCKET_WORKSPACE_ACCESS_TOKEN_RESPONSE_MAX_BYTES) + ) { + throw new Error('invalid_response'); + } + const text = await response.text(); + if (text.length > BITBUCKET_WORKSPACE_ACCESS_TOKEN_RESPONSE_MAX_BYTES) { + throw new Error('invalid_response'); + } + return JSON.parse(text); +} + +/** + * Ask the git-token-service to release the workspace access token of the + * organization integration. The service re-verifies the workspace identity + * against its own database before releasing, so a stale integration id can + * never release another workspace's token. Every transport or schema failure + * degrades to `temporarily_unavailable` — the client never throws past this + * union. + */ +export async function fetchBitbucketWorkspaceAccessToken(input: { + userId: string; + organizationId: string; + integrationId: string; + expectedWorkspace: BitbucketWorkspace; +}): Promise { + if (!GIT_TOKEN_SERVICE_API_URL) return { status: 'temporarily_unavailable' }; + + let serviceToken: string; + try { + serviceToken = generateInternalServiceToken(input.userId, { + expiresIn: TOKEN_EXPIRY.fiveMinutes, + audience: BITBUCKET_WORKSPACE_ACCESS_TOKEN_AUDIENCE, + organizationId: input.organizationId, + }); + } catch { + return { status: 'temporarily_unavailable' }; + } + + let response: Response; + try { + response = await fetch( + `${GIT_TOKEN_SERVICE_API_URL}${BITBUCKET_WORKSPACE_ACCESS_TOKEN_RELEASE_PATH}`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${serviceToken}`, + }, + body: JSON.stringify({ + integrationId: input.integrationId, + workspaceUuid: input.expectedWorkspace.uuid, + workspaceSlug: input.expectedWorkspace.slug, + }), + redirect: 'error', + signal: AbortSignal.timeout(BITBUCKET_WORKSPACE_ACCESS_TOKEN_REQUEST_TIMEOUT_MS), + } + ); + } catch { + return { status: 'temporarily_unavailable' }; + } + if (!response.ok || response.redirected) return { status: 'temporarily_unavailable' }; + + try { + const parsed = BitbucketWorkspaceAccessTokenReleaseResultSchema.safeParse( + await readBoundedReleaseJson(response) + ); + if (!parsed.success) return { status: 'temporarily_unavailable' }; + // A released token is only usable for the workspace it was requested + // for: refuse a workspace identity that does not match the integration. + if (parsed.data.status === 'available') { + const released = parsed.data; + if ( + released.workspace.uuid !== input.expectedWorkspace.uuid || + released.workspace.slug !== input.expectedWorkspace.slug + ) { + return { status: 'reconnect_required' }; + } + } + return parsed.data; + } catch { + return { status: 'temporarily_unavailable' }; + } +} + +function releaseFailureToReviewError(status: BitbucketWorkspaceAccessTokenReleaseResult['status']) { + switch (status) { + case 'not_connected': + return new BitbucketReviewError( + 'not_found', + 'The Bitbucket connection is no longer active. Reconnect Bitbucket to continue.' + ); + case 'reconnect_required': + return new BitbucketReviewError( + 'not_found', + 'The Bitbucket connection is no longer active. Reconnect Bitbucket to continue.' + ); + case 'invalid_request': + return new BitbucketReviewError('bad_request', 'Bitbucket rejected the credential request.'); + default: + return new BitbucketReviewError( + 'retryable', + 'Bitbucket credentials are temporarily unavailable.' + ); + } +} + +const BITBUCKET_WORKSPACE_SLUG_SCHEMA = z.string().regex(/^[a-z0-9][a-z0-9_.-]*$/); + +function cleanSlugSegment(value: string): string { + return value.trim().replace(/^\/+|\/+$/g, ''); +} + +/** The connected workspace identity and integration id of the org integration. */ +type ResolvedWorkspaceIntegration = { + workspace: BitbucketWorkspace; + integrationId: string; +}; + +/** + * Resolve the active organization workspace-access-token integration's + * identity. This is the only source of the workspace UUID (`platform_account_id`) + * and slug, and no function below accepts a workspace identity from a caller. + */ +async function resolveWorkspaceIntegration( + owner: BitbucketReviewOwner +): Promise { + if (owner.type !== 'organization') { + // Bitbucket Cloud has no personal integration: an understandable state in + // personal context is a clear refusal, never a partial workflow. + throw new BitbucketReviewError('not_found', BITBUCKET_ORGANIZATION_ONLY_MESSAGE); + } + + const status = await getBitbucketWorkspaceAccessTokenStatus(owner.organizationId); + if (status.status === 'not_connected') { + throw new BitbucketReviewError( + 'not_found', + 'No Bitbucket connection found for this organization. Connect Bitbucket first.' + ); + } + if (status.status !== 'connected' || !status.workspace || !status.integrationId) { + throw new BitbucketReviewError( + 'not_found', + 'The Bitbucket connection is no longer active. Reconnect Bitbucket to continue.' + ); + } + return { + workspace: { uuid: status.workspace.uuid, slug: status.workspace.slug }, + integrationId: status.integrationId, + }; +} + +/** + * Resolve the active organization integration and release its workspace + * access token. The inbox has no repository to verify, so it uses this + * instead of authorizeRepository. + */ +export async function authorizeWorkspace( + owner: BitbucketReviewOwner +): Promise { + if (owner.type !== 'organization') { + // Bitbucket Cloud has no personal integration: an understandable state in + // personal context is a clear refusal, never a partial workflow. + throw new BitbucketReviewError('not_found', BITBUCKET_ORGANIZATION_ONLY_MESSAGE); + } + const resolved = await resolveWorkspaceIntegration(owner); + const accessToken = await releaseWorkspaceAccessToken({ + owner, + workspace: resolved.workspace, + integrationId: resolved.integrationId, + }); + return { accessToken, workspace: resolved.workspace, owner }; +} + +/** + * Verify the repository belongs to the connected workspace by matching the + * integration's repository cache the same way the repository-cache and + * code-review flows do (exact `workspace/repo` full name, case-insensitive). + * A repository outside the cache is a clear not_found — the cache is the + * authorization boundary. The workspace access token is released only after + * the identity checks pass. + */ +export async function authorizeRepository( + owner: BitbucketReviewOwner, + workspaceSlug: string, + repoSlug: string +): Promise { + const requestedWorkspace = cleanSlugSegment(workspaceSlug).toLowerCase(); + const requestedRepository = cleanSlugSegment(repoSlug).toLowerCase(); + if ( + !BITBUCKET_WORKSPACE_SLUG_SCHEMA.safeParse(requestedWorkspace).success || + requestedRepository.length === 0 || + requestedRepository.includes('/') + ) { + throw new BitbucketReviewError( + 'not_found', + 'The Bitbucket repository could not be found with the given identity.' + ); + } + if (owner.type !== 'organization') { + throw new BitbucketReviewError('not_found', BITBUCKET_ORGANIZATION_ONLY_MESSAGE); + } + + const resolved = await resolveWorkspaceIntegration(owner); + // A pasted identity pointing at another workspace must never read the + // connected workspace's repositories: refuse as not-found without revealing + // the connected workspace. + if (requestedWorkspace !== resolved.workspace.slug.toLowerCase()) { + throw new BitbucketReviewError( + 'not_found', + 'This pull request is not available in your connected Bitbucket workspace.' + ); + } + + const cache = await readCachedBitbucketWorkspaceAccessTokenRepositories({ + organizationId: owner.organizationId, + expectedIntegrationId: resolved.integrationId, + }); + if (cache.status === 'not_connected' || cache.status === 'reconnect_required') { + throw new BitbucketReviewError( + 'not_found', + 'The Bitbucket connection is no longer active. Reconnect Bitbucket to continue.' + ); + } + if (cache.status === 'invalid_request') { + throw new BitbucketReviewError('bad_request', 'Bitbucket rejected the repository request.'); + } + // A permanent token-scope failure: the connected integration cannot list the + // workspace's repositories, so no retry helps. Name the remedy instead of + // folding it into a generic "try again" (the retryable fallback below). + if (cache.status === 'insufficient_permissions') { + throw new BitbucketReviewError( + 'forbidden', + 'The Bitbucket connection is missing the repository scope. Reconnect Bitbucket with repository read access to continue.' + ); + } + if (cache.status !== 'available') { + throw new BitbucketReviewError( + 'retryable', + 'The Bitbucket repository list is temporarily unavailable. Try again.' + ); + } + + const requestedFullName = `${requestedWorkspace}/${requestedRepository}`; + const match = cache.repositories.find( + repository => repository.fullName.toLowerCase() === requestedFullName + ); + if (!match) { + throw new BitbucketReviewError( + 'not_found', + 'This repository is not part of your connected Bitbucket workspace.' + ); + } + const matchRepository = match.fullName.split('/')[1]; + + const accessToken = await releaseWorkspaceAccessToken({ + owner, + workspace: resolved.workspace, + integrationId: resolved.integrationId, + }); + return { + accessToken, + workspace: resolved.workspace, + repository: { + uuid: match.id, + slug: matchRepository ?? requestedRepository, + fullName: match.fullName, + }, + owner, + }; +} + +async function releaseWorkspaceAccessToken(input: { + owner: BitbucketReviewOwner & { type: 'organization' }; + workspace: BitbucketWorkspace; + integrationId: string; +}): Promise { + const released = await fetchBitbucketWorkspaceAccessToken({ + userId: input.owner.userId, + organizationId: input.owner.organizationId, + integrationId: input.integrationId, + expectedWorkspace: input.workspace, + }); + if (released.status !== 'available') { + throw releaseFailureToReviewError(released.status); + } + return released.token; +} 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..eff6642109 --- /dev/null +++ b/apps/web/src/lib/provider-review/bitbucket-read.test.ts @@ -0,0 +1,1294 @@ +import { describe, expect, it, beforeEach, afterEach } from '@jest/globals'; +import { + getMergeRestrictions, + getPullRequest, + getReviewStatus, + getFileLines, + listChangedFiles, + listChecks, + listDiscussions, + listInbox, +} from './bitbucket-read'; +import { BitbucketReviewError } from './bitbucket-authorization'; + +const mockGetBitbucketWorkspaceAccessTokenStatus = jest.fn(); +const mockReadCachedRepositories = jest.fn(); + +jest.mock('@/lib/integrations/platforms/bitbucket/workspace-access-token-repository-cache', () => ({ + getBitbucketWorkspaceAccessTokenStatus: (...args: unknown[]) => + mockGetBitbucketWorkspaceAccessTokenStatus(...args), + readCachedBitbucketWorkspaceAccessTokenRepositories: (input: unknown) => + mockReadCachedRepositories(input), +})); + +jest.mock('@/lib/config.server', () => ({ + GIT_TOKEN_SERVICE_API_URL: 'https://token-service.example.com', +})); + +jest.mock('@/lib/tokens', () => ({ + generateInternalServiceToken: jest.fn(() => 'svc-mock-token'), + TOKEN_EXPIRY: { fiveMinutes: 300 }, +})); + +jest.mock('@/lib/utils.server', () => ({ + logExceptInTest: () => {}, + warnExceptInTest: () => {}, +})); + +const ORG_OWNER = { + type: 'organization' as const, + organizationId: 'org_1', + userId: 'user_1', +}; + +const WORKSPACE = { uuid: '12345678-1234-1234-1234-123456789012', slug: 'acme' }; + +/** Recorded Bitbucket REST payload shapes (structure, not live data). */ +const prDetail = { + id: 12, + title: 'Add retry fingerprints', + state: 'OPEN', + draft: false, + summary: { raw: 'Adds collision-free retry fingerprints.' }, + task_count: 1, + author: { + uuid: '{author-uuid}', + nickname: 'alice', + display_name: 'Alice', + links: { avatar: { href: 'https://bitbucket.org/account/alice/avatar/32' } }, + }, + source: { + branch: { name: 'feature/retry' }, + commit: { hash: 'abc123def4567890' }, + repository: { full_name: 'acme/repo', uuid: '{repo-uuid}' }, + }, + destination: { + branch: { name: 'main' }, + commit: { hash: 'bd4567890abcdef12' }, + repository: { full_name: 'acme/repo', uuid: '{repo-uuid}' }, + }, + created_on: '2026-09-01T00:00:00.000000+00:00', + updated_on: '2026-09-03T00:00:00.000000+00:00', + links: { html: { href: 'https://bitbucket.org/acme/repo/pull-requests/12' } }, + participants: [ + { + user: { uuid: '{reviewer-uuid}', nickname: 'bob', display_name: 'Bob' }, + role: 'REVIEWER', + approved: true, + state: 'approved', + }, + { + user: { uuid: '{author-uuid}', nickname: 'alice', display_name: 'Alice' }, + role: 'PARTICIPANT', + approved: false, + state: null, + }, + ], +}; + +const diffstatPage1 = { + pagelen: 2, + values: [ + { + status: 'modified', + lines_added: 3, + lines_removed: 1, + old: { path: 'src/retry.ts' }, + new: { path: 'src/retry.ts' }, + }, + { + status: 'added', + lines_added: 2, + lines_removed: 0, + old: null, + new: { path: 'src/fingerprint.ts' }, + }, + ], + next: 'https://api.bitbucket.org/2.0/repositories/acme/repo/pullrequests/12/diffstat?pagelen=2&page=2', +}; + +const diffstatPage2 = { + pagelen: 2, + values: [ + { + status: 'removed', + lines_added: 0, + lines_removed: 4, + old: { path: 'src/old.ts' }, + new: null, + }, + ], +}; + +const commentFixture = { + pagelen: 50, + values: [ + { + id: 101, + content: { raw: 'General remark' }, + created_on: '2026-09-02T10:00:00.000000+00:00', + user: { uuid: '{reviewer-uuid}', nickname: 'bob', display_name: 'Bob' }, + deleted: false, + }, + { + id: 102, + parent: { id: 101 }, + content: { raw: 'Reply from the author' }, + created_on: '2026-09-02T11:00:00.000000+00:00', + user: { uuid: '{author-uuid}', nickname: 'alice', display_name: 'Alice' }, + deleted: false, + }, + { + id: 103, + content: { raw: 'Inline note' }, + inline: { path: 'src/retry.ts', from: null, to: 12 }, + created_on: '2026-09-02T12:00:00.000000+00:00', + user: { uuid: '{reviewer-uuid}', nickname: 'bob', display_name: 'Bob' }, + deleted: false, + }, + { + id: 104, + content: { raw: 'Deleted comment' }, + deleted: true, + }, + ], + next: null, +}; + +const taskFixture = { + pagelen: 100, + values: [ + { + id: 7, + resolved_on: null, + comment: { id: 101 }, + }, + ], + next: null, +}; + +const buildStatusesFixture = { + pagelen: 10, + values: [ + { + state: 'SUCCESSFUL', + key: 'pipeline.build', + name: 'Build and test', + url: 'https://bitbucket.org/acme/repo/pipelines/results/1', + links: { status: { href: 'https://bitbucket.org/acme/repo/pipelines/results/1' } }, + }, + { + state: 'INPROGRESS', + key: 'pipeline.deploy', + name: 'Deploy', + url: 'https://bitbucket.org/acme/repo/pipelines/results/2', + }, + ], + next: null, +}; + +const branchRestrictionsFixture = { + pagelen: 10, + values: [ + { kind: 'require_approvals_to_merge', value: 2 }, + { kind: 'require_passing_builds_to_merge', value: null }, + { kind: 'require_tasks_to_be_completed', value: null }, + ], + next: null, +}; + +let fetchMock: jest.Mock; + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +/** Await a rejection and return it typed, without a success-branch union. */ +async function captureRejection(promise: Promise): Promise { + try { + await promise; + } catch (reason) { + return reason as BitbucketReviewError; + } + throw new Error('Expected the call to reject.'); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockGetBitbucketWorkspaceAccessTokenStatus.mockResolvedValue({ + status: 'connected', + integrationId: 'intg_1', + workspace: { ...WORKSPACE, displayName: 'Acme' }, + }); + mockReadCachedRepositories.mockResolvedValue({ + status: 'available', + repositories: [ + { + id: '87654321-4321-4321-4321-210987654321', + workspaceUuid: WORKSPACE.uuid, + name: 'repo', + fullName: 'acme/repo', + private: true, + defaultBranch: 'main', + }, + ], + syncedAt: '2026-09-06T00:00:00.000Z', + }); + fetchMock = jest.fn(); + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + const parsed = new URL(full); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + if (parsed.pathname.endsWith('/pullrequests/12/diffstat')) { + return parsed.searchParams.get('page') === '2' + ? jsonResponse(diffstatPage2) + : jsonResponse(diffstatPage1); + } + if (parsed.pathname.endsWith('/pullrequests/12/comments')) return jsonResponse(commentFixture); + if (parsed.pathname.endsWith('/pullrequests/12/tasks')) return jsonResponse(taskFixture); + if (parsed.pathname.endsWith('/commit/abc123def4567890/statuses')) { + return jsonResponse(buildStatusesFixture); + } + if (parsed.pathname.endsWith('/branch-restrictions')) { + return jsonResponse(branchRestrictionsFixture); + } + if (parsed.pathname.endsWith('/pullrequests/12')) return jsonResponse(prDetail); + if (parsed.pathname.includes('/src/')) { + return new Response('line one\nline two\nline three', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('getPullRequest', () => { + it('maps the recorded detail into the s1 summary with source.commit.hash as headSha', async () => { + const summary = await getPullRequest(ORG_OWNER, 'acme', 'repo', 12); + + expect(summary.ref).toEqual({ + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + }); + expect(summary).toMatchObject({ + title: 'Add retry fingerprints', + body: 'Adds collision-free retry fingerprints.', + author: { login: 'alice', avatarUrl: 'https://bitbucket.org/account/alice/avatar/32' }, + state: 'open', + draft: false, + headRef: 'feature/retry', + baseRef: 'main', + headSha: 'abc123def4567890', + changedFiles: 3, + additions: 5, + deletions: 5, + webUrl: 'https://bitbucket.org/acme/repo/pull-requests/12', + }); + }); + + it('maps MERGED and DECLINED provider states onto the shared lifecycle', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + if (new URL(full).pathname.endsWith('/pullrequests/12')) { + return jsonResponse({ ...prDetail, state: 'MERGED' }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const summary = await getPullRequest(ORG_OWNER, 'acme', 'repo', 12); + + expect(summary.state).toBe('merged'); + }); + + it('follows a same-origin 302 the diffstat endpoint answers with', async () => { + const redirectTarget = + 'https://api.bitbucket.org/2.0/repositories/acme/repo/diffstat/acme/repo:abc%0Ddef?pagelen=50&from_pullrequest_id=12&topic=true'; + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + if (new URL(full).pathname.endsWith('/pullrequests/12')) { + return jsonResponse(prDetail); + } + if (full === redirectTarget) { + return jsonResponse(diffstatPage1); + } + if (new URL(full).pathname.endsWith('/pullrequests/12/diffstat')) { + return new Response(null, { + status: 302, + headers: { location: redirectTarget }, + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const result = await listChangedFiles(ORG_OWNER, 'acme', 'repo', 12); + + expect(result.files).toHaveLength(diffstatPage1.values.length); + expect(fetchMock).toHaveBeenCalledWith(redirectTarget, expect.anything()); + }); + + it('raises the bare status for a redirect that leaves the Bitbucket API origin', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + if (new URL(full).pathname.endsWith('/pullrequests/12')) { + return jsonResponse(prDetail); + } + if (new URL(full).pathname.endsWith('/pullrequests/12/diffstat')) { + return new Response(null, { + status: 302, + headers: { location: 'https://evil.example.com/2.0/steal' }, + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const error = await captureRejection(listChangedFiles(ORG_OWNER, 'acme', 'repo', 12)); + + expect(error.kind).toBe('retryable'); + expect(error.message).toBe('Bitbucket returned an unexpected error.'); + }); +}); + +describe('listChangedFiles — pagination', () => { + it('maps diffstat entries into the shared file DTO', async () => { + const result = await listChangedFiles(ORG_OWNER, 'acme', 'repo', 12); + + expect(result.files).toHaveLength(2); + expect(result.files[0]).toMatchObject({ + path: 'src/retry.ts', + previousPath: null, + status: 'modified', + additions: 3, + deletions: 1, + }); + expect(result.files[1]).toMatchObject({ path: 'src/fingerprint.ts', status: 'added' }); + expect(result.nextCursor).not.toBeNull(); + }); + + it('follows the encoded provider next URL with a fresh token on page 2', async () => { + const page1 = await listChangedFiles(ORG_OWNER, 'acme', 'repo', 12); + expect(page1.nextCursor).not.toBeNull(); + + const page2 = await listChangedFiles(ORG_OWNER, 'acme', 'repo', 12, page1.nextCursor ?? ''); + + expect(page2.files).toHaveLength(1); + expect(page2.files[0]).toMatchObject({ + path: 'src/old.ts', + status: 'removed', + deletions: 4, + }); + expect(page2.nextCursor).toBeNull(); + // The last request carried the provider next URL against the fixed origin. + const last = fetchMock.mock.calls[fetchMock.mock.calls.length - 1][0] as string; + expect(last).toContain('api.bitbucket.org/2.0/repositories/acme/repo/pullrequests/12/diffstat'); + expect(last).toContain('page=2'); + }); + + it('ignores a cursor minted for another repository identity (reads page 1)', async () => { + const otherPr = await listChangedFiles(ORG_OWNER, 'acme', 'repo', 12); + expect(otherPr.nextCursor).not.toBeNull(); + + // A cursor for PR 12 is used against PR 13: the identity check fails and + // the request restarts from page 1 of PR 13's diffstat. + const result = await listChangedFiles(ORG_OWNER, 'acme', 'repo', 13, otherPr.nextCursor ?? ''); + const last = fetchMock.mock.calls[fetchMock.mock.calls.length - 1][0] as string; + expect(last).toContain('/pullrequests/13/diffstat'); + expect(last).not.toContain('page=2'); + expect(result.nextCursor).toBeNull(); + }); + + it('ends pagination on a provider next link outside the guarded repository path', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + if (new URL(full).pathname.endsWith('/pullrequests/12/diffstat')) { + return jsonResponse({ + pagelen: 1, + values: [ + { + status: 'modified', + lines_added: 1, + lines_removed: 0, + old: null, + new: { path: 'src/x.ts' }, + }, + ], + next: 'https://evil.example.com/2.0/repositories/acme/repo/pullrequests/12/diffstat?page=2', + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const result = await listChangedFiles(ORG_OWNER, 'acme', 'repo', 12); + + expect(result.files).toHaveLength(1); + expect(result.nextCursor).toBeNull(); + }); +}); + +describe('getFileLines', () => { + it('returns the 1-based inclusive line window of the file at the commit', async () => { + const result = await getFileLines( + ORG_OWNER, + 'acme', + 'repo', + 'abc123def4567890', + 'src/retry.ts', + 2, + 3 + ); + + expect(result.lines).toEqual(['line two', 'line three']); + expect(result.totalLines).toBe(3); + }); + + it('refuses a non-commit ref before any Bitbucket API request', async () => { + await expect( + getFileLines(ORG_OWNER, 'acme', 'repo', '../../etc/passwd', 'src/retry.ts', 1, 2) + ).rejects.toMatchObject({ kind: 'bad_request' }); + // Only the credential release may have run; no provider request was made. + expect( + fetchMock.mock.calls.filter(call => String(call[0]).includes('api.bitbucket.org')) + ).toEqual([]); + }); + + it('maps a provider 404 to non-retryable not_found', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + if (full.includes('/src/')) return new Response(null, { status: 404 }); + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const error = await captureRejection( + getFileLines(ORG_OWNER, 'acme', 'repo', 'abc123def4567890', 'src/missing.ts', 1, 2) + ); + + expect(error).toBeInstanceOf(BitbucketReviewError); + expect(error.kind).toBe('not_found'); + expect(error.retryable).toBe(false); + }); +}); + +describe('listDiscussions', () => { + it('builds general and inline threads with replies and task-based resolution', async () => { + const result = await listDiscussions(ORG_OWNER, 'acme', 'repo', 12); + + expect(result.threads).toHaveLength(2); + + const general = result.threads[0]; + expect(general).toMatchObject({ + threadId: '101', + resolved: false, + path: null, + line: null, + side: null, + taskCount: 1, + }); + expect(general.comments.map(comment => comment.body)).toEqual([ + 'General remark', + 'Reply from the author', + ]); + + const inline = result.threads[1]; + expect(inline).toMatchObject({ + threadId: '103', + path: 'src/retry.ts', + line: 12, + side: 'RIGHT', + resolved: false, + taskCount: 0, + }); + }); + + it('marks a thread resolved when its only task is resolved', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + const pathname = new URL(full).pathname; + if (pathname.endsWith('/pullrequests/12/comments')) return jsonResponse(commentFixture); + if (pathname.endsWith('/pullrequests/12/tasks')) { + return jsonResponse({ + pagelen: 100, + values: [ + { id: 7, resolved_on: '2026-09-04T00:00:00.000000+00:00', comment: { id: 101 } }, + ], + next: null, + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const result = await listDiscussions(ORG_OWNER, 'acme', 'repo', 12); + + expect(result.threads[0]).toMatchObject({ threadId: '101', resolved: true }); + }); + + it('derives taskCount from the collected tasks and keeps a partially resolved thread unresolved', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + const pathname = new URL(full).pathname; + if (pathname.endsWith('/pullrequests/12/comments')) return jsonResponse(commentFixture); + if (pathname.endsWith('/pullrequests/12/tasks')) { + return jsonResponse({ + pagelen: 100, + values: [ + { id: 7, resolved_on: '2026-09-04T00:00:00.000000+00:00', comment: { id: 101 } }, + { id: 8, resolved_on: null, comment: { id: 101 } }, + { id: 9, resolved_on: null, comment: { id: 103 } }, + ], + next: null, + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const result = await listDiscussions(ORG_OWNER, 'acme', 'repo', 12); + + expect(result.threads[0]).toMatchObject({ threadId: '101', resolved: false, taskCount: 2 }); + expect(result.threads[1]).toMatchObject({ threadId: '103', resolved: false, taskCount: 1 }); + }); + + it('keeps threads unreadable-to-resolve when the task collection is not exposed', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + const pathname = new URL(full).pathname; + if (pathname.endsWith('/pullrequests/12/comments')) return jsonResponse(commentFixture); + if (pathname.endsWith('/pullrequests/12/tasks')) return new Response(null, { status: 404 }); + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const result = await listDiscussions(ORG_OWNER, 'acme', 'repo', 12); + + expect(result.threads).toHaveLength(2); + expect(result.threads.every(thread => thread.resolved === false)).toBe(true); + }); + + it('keeps a thread unresolved when an unresolved task sits on a later task page', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + const parsed = new URL(full); + if (parsed.pathname.endsWith('/pullrequests/12/comments')) + return jsonResponse(commentFixture); + if (parsed.pathname.endsWith('/pullrequests/12/tasks')) { + // Page 1 holds a resolved task for comment 101, page 2 an unresolved + // one: reading only page 1 would claim a resolution the full + // collection contradicts. + return parsed.searchParams.get('page') === '2' + ? jsonResponse({ + pagelen: 100, + values: [{ id: 8, resolved_on: null, comment: { id: 101 } }], + next: null, + }) + : jsonResponse({ + pagelen: 100, + values: [ + { id: 7, resolved_on: '2026-09-04T00:00:00.000000+00:00', comment: { id: 101 } }, + ], + next: 'https://api.bitbucket.org/2.0/repositories/acme/repo/pullrequests/12/tasks?pagelen=100&page=2', + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const result = await listDiscussions(ORG_OWNER, 'acme', 'repo', 12); + + expect(result.threads[0]).toMatchObject({ threadId: '101', resolved: false, taskCount: 2 }); + // The collection was followed to page 2 before the evidence was folded. + expect( + fetchMock.mock.calls.filter(call => + new URL(String(call[0])).pathname.endsWith('/pullrequests/12/tasks') + ) + ).toHaveLength(2); + }); + + it('reports no task evidence when the task collection exceeds the page bound', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + const parsed = new URL(full); + if (parsed.pathname.endsWith('/pullrequests/12/comments')) + return jsonResponse(commentFixture); + if (parsed.pathname.endsWith('/pullrequests/12/tasks')) { + // Every page resolves comment 101 and points at a further page: past + // the walk bound the evidence is unverified, so it must not claim a + // resolution the unread pages could contradict. + const pageIndex = Number(parsed.searchParams.get('page') ?? '1'); + return jsonResponse({ + pagelen: 100, + values: [ + { + id: 100 + pageIndex, + resolved_on: '2026-09-04T00:00:00.000000+00:00', + comment: { id: 101 }, + }, + ], + next: `https://api.bitbucket.org/2.0/repositories/acme/repo/pullrequests/12/tasks?pagelen=100&page=${pageIndex + 1}`, + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const result = await listDiscussions(ORG_OWNER, 'acme', 'repo', 12); + + expect(result.threads[0]).toMatchObject({ threadId: '101', resolved: false, taskCount: 0 }); + expect(result.threads[1]).toMatchObject({ threadId: '103', resolved: false, taskCount: 0 }); + // The walk stops at the bound instead of crawling an unbounded collection. + expect( + fetchMock.mock.calls.filter(call => + new URL(String(call[0])).pathname.endsWith('/pullrequests/12/tasks') + ) + ).toHaveLength(10); + }); +}); + +describe('listChecks', () => { + it('maps commit build statuses onto the shared checks DTO', async () => { + const result = await listChecks(ORG_OWNER, 'acme', 'repo', 12); + + expect(result.checks).toEqual([ + { + name: 'Build and test', + status: 'completed', + conclusion: 'success', + detailsUrl: 'https://bitbucket.org/acme/repo/pipelines/results/1', + }, + { + name: 'Deploy', + status: 'pending', + conclusion: null, + detailsUrl: 'https://bitbucket.org/acme/repo/pipelines/results/2', + }, + ]); + }); + + it('maps a failed build to conclusion failed', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + const pathname = new URL(full).pathname; + if (pathname.endsWith('/pullrequests/12')) return jsonResponse(prDetail); + if (pathname.endsWith('/statuses')) { + return jsonResponse({ + pagelen: 10, + values: [{ state: 'FAILED', key: 'pipeline.build', name: 'Build and test', url: null }], + next: null, + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const result = await listChecks(ORG_OWNER, 'acme', 'repo', 12); + + expect(result.checks[0]).toMatchObject({ status: 'completed', conclusion: 'failed' }); + }); + + it('returns no checks when the PR has no source commit', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + if (new URL(full).pathname.endsWith('/pullrequests/12')) { + return jsonResponse({ + ...prDetail, + source: { branch: { name: 'feature/retry' }, commit: null }, + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const result = await listChecks(ORG_OWNER, 'acme', 'repo', 12); + + expect(result.checks).toEqual([]); + }); +}); + +describe('listInbox', () => { + const inboxPr = (id: number, updatedOn: string, fullName = 'acme/repo') => ({ + id, + title: `PR ${id}`, + state: 'OPEN', + draft: false, + author: { uuid: '{author-uuid}', nickname: 'alice', display_name: 'Alice' }, + updated_on: updatedOn, + source: { branch: { name: 'feature/retry' }, repository: { full_name: fullName } }, + destination: { branch: { name: 'main' }, repository: { full_name: fullName } }, + }); + + it('fans out over the workspace repositories and carries full identity', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + const parsed = new URL(full); + if (parsed.pathname === '/2.0/repositories/acme') { + return jsonResponse({ + pagelen: 100, + values: [{ slug: 'repo' }, { slug: 'empty-repo' }], + next: null, + }); + } + if (parsed.pathname === '/2.0/repositories/acme/repo/pullrequests') { + expect(parsed.searchParams.get('q')).toBe('state="OPEN"'); + // The provider listing is pinned to the inbox's own sort key, so the + // merged windows continue each other across pages. + expect(parsed.searchParams.get('sort')).toBe('-updated_on'); + return jsonResponse({ + pagelen: 50, + values: [ + inboxPr(12, '2026-09-02T00:00:00.000000+00:00'), + { + id: 13, + title: 'Foreign workspace PR', + state: 'OPEN', + draft: false, + updated_on: '2026-09-03T00:00:00.000000+00:00', + destination: { repository: { full_name: 'other-ws/other-repo' } }, + }, + { + id: 14, + title: 'No repository identity', + state: 'OPEN', + draft: false, + updated_on: null, + }, + ], + next: null, + }); + } + if (parsed.pathname === '/2.0/repositories/acme/empty-repo/pullrequests') { + return jsonResponse({ pagelen: 50, values: [], next: null }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const result = await listInbox(ORG_OWNER); + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + ref: { platform: 'bitbucket', workspace: 'acme', repoSlug: 'repo', prId: 12 }, + title: 'PR 12', + author: { login: 'alice' }, + state: 'open', + draft: false, + }); + expect(result.nextCursor).toBeNull(); + }); + + it('merges pages across repositories newest first and continues with a page cursor', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + const parsed = new URL(full); + if (parsed.pathname === '/2.0/repositories/acme') { + return jsonResponse({ pagelen: 100, values: [{ slug: 'repo' }], next: null }); + } + if (parsed.pathname === '/2.0/repositories/acme/repo/pullrequests') { + // The provider listing is sorted newest first (the inbox request pins + // sort=-updated_on): page 2 holds only older rows. + const page = parsed.searchParams.get('page'); + if (page === '2') { + return jsonResponse({ + pagelen: 50, + values: [inboxPr(99, '2026-09-01T00:00:00.000000+00:00')], + next: null, + }); + } + return jsonResponse({ + pagelen: 50, + values: Array.from({ length: 50 }, (_, index) => + inboxPr(100 + index, `2026-09-02T00:00:${String(index).padStart(2, '0')}+00:00`) + ), + next: null, + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const first = await listInbox(ORG_OWNER); + expect(first.items).toHaveLength(50); + expect(first.items[0]?.ref).toMatchObject({ prId: 149 }); + expect(first.nextCursor).toBeTruthy(); + + const second = await listInbox(ORG_OWNER, first.nextCursor!); + // The second page continues the sorted sequence with the rows the first + // page's trim could not serve, instead of re-sorting a fresh fan-out. + expect(second.items.map(item => item.ref)).toEqual([expect.objectContaining({ prId: 99 })]); + expect(second.nextCursor).toBeNull(); + }); + + it('serves every row across pages: the sorted window refetches earlier provider pages', async () => { + // 60 PRs in one repository: a full first provider page (50) plus a + // 10-row older tail. Page 1 serves the newest 50; page 2 must serve the + // remaining 10 — none dropped, none duplicated. + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + const parsed = new URL(full); + if (parsed.pathname === '/2.0/repositories/acme') { + return jsonResponse({ pagelen: 100, values: [{ slug: 'repo' }], next: null }); + } + if (parsed.pathname === '/2.0/repositories/acme/repo/pullrequests') { + const page = parsed.searchParams.get('page'); + if (page === '2') { + return jsonResponse({ + pagelen: 50, + values: Array.from({ length: 10 }, (_, index) => + inboxPr(150 + index, `2026-09-01T00:00:${String(index).padStart(2, '0')}+00:00`) + ), + next: null, + }); + } + return jsonResponse({ + pagelen: 50, + values: Array.from({ length: 50 }, (_, index) => + inboxPr(100 + index, `2026-09-02T00:00:${String(index).padStart(2, '0')}+00:00`) + ), + next: null, + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const first = await listInbox(ORG_OWNER); + expect(first.items).toHaveLength(50); + expect(first.items[0]?.ref).toMatchObject({ prId: 149 }); + + const second = await listInbox(ORG_OWNER, first.nextCursor!); + expect(second.items).toHaveLength(10); + expect(second.items[0]?.ref).toMatchObject({ prId: 159 }); + expect(second.nextCursor).toBeNull(); + + const servedIds = new Set( + [...first.items, ...second.items].map(item => + item.ref && 'prId' in item.ref ? item.ref.prId : null + ) + ); + expect(servedIds.size).toBe(60); + for (let id = 100; id <= 159; id += 1) { + expect(servedIds.has(id)).toBe(true); + } + }); + + it('serves every row of several repositories across pages without drops', async () => { + // Two repositories with 50 open PRs each: page 1 serves the newest 50 of + // the 100 aggregated rows, page 2 the remaining 50 — the old page-N + // fan-out re-fetched provider page 2 (empty here) and dropped 50 rows. + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + const parsed = new URL(full); + if (parsed.pathname === '/2.0/repositories/acme') { + return jsonResponse({ + pagelen: 100, + values: [{ slug: 'repo-a' }, { slug: 'repo-b' }], + next: null, + }); + } + const match = parsed.pathname.match( + /^\/2\.0\/repositories\/acme\/(repo-[ab])\/pullrequests$/ + ); + if (match && parsed.searchParams.get('page') === '1') { + // repo-a rows are older than repo-b rows, so the first inbox page is + // exactly repo-b's page; the single provider page holds all 50 rows. + const base = match[1] === 'repo-a' ? 100 : 200; + const hour = match[1] === 'repo-a' ? '00' : '01'; + return jsonResponse({ + pagelen: 50, + values: Array.from({ length: 50 }, (_, index) => + inboxPr( + base + index, + `2026-09-02T${hour}:00:${String(index).padStart(2, '0')}.000000+00:00` + ) + ), + next: null, + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const first = await listInbox(ORG_OWNER); + expect(first.items).toHaveLength(50); + expect(first.items[0]?.ref).toMatchObject({ prId: 249 }); + expect(first.nextCursor).toBeTruthy(); + + const second = await listInbox(ORG_OWNER, first.nextCursor!); + expect(second.items).toHaveLength(50); + expect(second.items[0]?.ref).toMatchObject({ prId: 149 }); + expect(second.nextCursor).toBeNull(); + + const servedIds = new Set( + [...first.items, ...second.items].map(item => + item.ref && 'prId' in item.ref ? item.ref.prId : null + ) + ); + expect(servedIds.size).toBe(100); + for (let id = 100; id <= 249; id += 1) { + if ((id >= 100 && id <= 149) || (id >= 200 && id <= 249)) { + expect(servedIds.has(id)).toBe(true); + } + } + }); + + it('ignores a cursor minted for another workspace', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + const parsed = new URL(full); + if (parsed.pathname === '/2.0/repositories/acme') { + return jsonResponse({ pagelen: 100, values: [{ slug: 'repo' }], next: null }); + } + if (parsed.pathname === '/2.0/repositories/acme/repo/pullrequests') { + expect(parsed.searchParams.get('page')).toBe('1'); + return jsonResponse({ + pagelen: 50, + values: [inboxPr(12, '2026-09-02T00:00:00.000000+00:00')], + next: null, + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const foreign = Buffer.from( + JSON.stringify({ identity: 'bitbucket-inbox:evil', page: 7 }) + ).toString('base64url'); + const result = await listInbox(ORG_OWNER, foreign); + expect(result.items).toHaveLength(1); + }); +}); + +describe('getMergeRestrictions', () => { + it('derives the merge gate from draft state, tasks, approvals, and merge checks', async () => { + const state = await getMergeRestrictions(ORG_OWNER, 'acme', 'repo', 12); + + expect(state).toMatchObject({ + canMerge: false, + approvalsRequired: 2, + pipelineMustSucceed: true, + conflicts: false, + }); + const codes = state.blockedReasons.map(reason => reason.code); + expect(codes).toContain('required_approvals'); + expect(codes).toContain('pending_pipeline'); + // One approved reviewer against a requirement of two: one approval left. + expect(state.blockedReasons).toContainEqual({ + code: 'required_approvals', + message: '1 more approval required.', + }); + }); + + it('reads mergeable when the PR is open, reviewed, and its checks pass', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + const pathname = new URL(full).pathname; + if (pathname.endsWith('/pullrequests/12')) { + return jsonResponse({ ...prDetail, task_count: 0 }); + } + if (pathname.endsWith('/statuses')) { + return jsonResponse({ + pagelen: 10, + values: [{ state: 'SUCCESSFUL', key: 'pipeline.build', name: 'Build', url: null }], + next: null, + }); + } + if (pathname.endsWith('/branch-restrictions')) { + return jsonResponse({ + pagelen: 10, + values: [ + { kind: 'require_approvals_to_merge', value: 1 }, + { kind: 'require_passing_builds_to_merge', value: null }, + ], + next: null, + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const state = await getMergeRestrictions(ORG_OWNER, 'acme', 'repo', 12); + + expect(state.canMerge).toBe(true); + expect(state.blockedReasons).toEqual([]); + }); + + it('blocks a draft pull request with the provider wording', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + const pathname = new URL(full).pathname; + if (pathname.endsWith('/pullrequests/12')) { + return jsonResponse({ ...prDetail, draft: true, task_count: 0 }); + } + return jsonResponse({ pagelen: 10, values: [], next: null }); + }); + + const state = await getMergeRestrictions(ORG_OWNER, 'acme', 'repo', 12); + + expect(state.blockedReasons).toContainEqual({ + code: 'draft', + message: 'The pull request is still a draft.', + }); + }); + + it('reports a conflicted pull request from the file-conflicts endpoint', async () => { + const conflictSpecs: string[] = []; + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + const pathname = new URL(full).pathname; + if (pathname.endsWith('/pullrequests/12')) { + return jsonResponse({ ...prDetail, task_count: 0 }); + } + // The provider's conflict verdict: one page over the PR's + // source..destination commit range. + const conflictsMatch = pathname.match(/\/file-conflicts\/([0-9a-f]+\.\.[0-9a-f]+)$/); + if (conflictsMatch) { + conflictSpecs.push(decodeURIComponent(conflictsMatch[1])); + return jsonResponse({ + pagelen: 100, + values: [ + { + type: 'conflict', + path: 'src/retry.ts', + scenario: 'content', + message: 'File modified in both source and destination', + }, + ], + next: null, + }); + } + if (pathname.endsWith('/statuses')) { + return jsonResponse({ + pagelen: 10, + values: [{ state: 'SUCCESSFUL', key: 'pipeline.build', name: 'Build', url: null }], + next: null, + }); + } + return jsonResponse({ pagelen: 10, values: [], next: null }); + }); + + const state = await getMergeRestrictions(ORG_OWNER, 'acme', 'repo', 12); + + // The range spec is the PR's own source and destination commits. + expect(conflictSpecs).toEqual(['abc123def4567890..bd4567890abcdef12']); + expect(state.conflicts).toBe(true); + expect(state.blockedReasons).toContainEqual({ + code: 'conflicts', + message: 'The pull request has conflicts that must be resolved.', + }); + expect(state.canMerge).toBe(false); + }); + + it('reports no conflict when the file-conflicts endpoint answers an empty page', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + const pathname = new URL(full).pathname; + if (pathname.endsWith('/pullrequests/12')) { + return jsonResponse({ ...prDetail, task_count: 0 }); + } + if (pathname.includes('/file-conflicts/')) { + return jsonResponse({ pagelen: 100, values: [], next: null }); + } + if (pathname.endsWith('/statuses')) { + return jsonResponse({ + pagelen: 10, + values: [{ state: 'SUCCESSFUL', key: 'pipeline.build', name: 'Build', url: null }], + next: null, + }); + } + return jsonResponse({ pagelen: 10, values: [], next: null }); + }); + + const state = await getMergeRestrictions(ORG_OWNER, 'acme', 'repo', 12); + + expect(state.conflicts).toBe(false); + }); + + it('keeps merging possible when the file-conflicts endpoint is unreadable', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + const pathname = new URL(full).pathname; + if (pathname.endsWith('/pullrequests/12')) { + return jsonResponse({ ...prDetail, task_count: 0 }); + } + if (pathname.includes('/file-conflicts/')) { + return new Response(null, { status: 403 }); + } + if (pathname.endsWith('/statuses')) { + return jsonResponse({ + pagelen: 10, + values: [{ state: 'SUCCESSFUL', key: 'pipeline.build', name: 'Build', url: null }], + next: null, + }); + } + if (pathname.endsWith('/branch-restrictions')) { + return jsonResponse({ + pagelen: 10, + values: [{ kind: 'require_approvals_to_merge', value: 1 }], + next: null, + }); + } + return jsonResponse({ pagelen: 10, values: [], next: null }); + }); + + const state = await getMergeRestrictions(ORG_OWNER, 'acme', 'repo', 12); + + expect(state.conflicts).toBe(false); + // The unreadable conflict surface must not turn into a bogus block. + expect(state.canMerge).toBe(true); + }); + + it('blocks merge on unresolved tasks even when the restriction list is unreadable', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + if (new URL(full).pathname.endsWith('/branch-restrictions')) { + return new Response(null, { status: 403 }); + } + return jsonResponse({ ...prDetail, task_count: 2 }); + }); + + const state = await getMergeRestrictions(ORG_OWNER, 'acme', 'repo', 12); + + expect(state.blockedReasons).toContainEqual({ + code: 'other', + message: 'Resolve all tasks before merging.', + }); + expect(state.canMerge).toBe(false); + }); + + it('treats unreadable branch restrictions as no visible merge gate', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + if (new URL(full).pathname.endsWith('/branch-restrictions')) { + return new Response(null, { status: 403 }); + } + return jsonResponse({ ...prDetail, task_count: 0 }); + }); + + const state = await getMergeRestrictions(ORG_OWNER, 'acme', 'repo', 12); + + expect(state.approvalsRequired).toBe(0); + expect(state.pipelineMustSucceed).toBe(false); + }); +}); + +describe('getReviewStatus', () => { + it('returns participants with approval state and REVIEWER role', async () => { + const status = await getReviewStatus(ORG_OWNER, 'acme', 'repo', 12); + + expect(status.participants).toEqual([ + { + login: 'bob', + avatarUrl: null, + approved: true, + reviewer: true, + }, + { + login: 'alice', + avatarUrl: null, + approved: false, + reviewer: false, + }, + ]); + }); + + it('carries the avatar link when the provider supplies one', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE }); + } + if (new URL(full).pathname.endsWith('/pullrequests/12')) { + return jsonResponse({ + ...prDetail, + participants: [ + { + user: { + uuid: '{reviewer-uuid}', + nickname: 'bob', + display_name: 'Bob', + links: { avatar: { href: 'https://bitbucket.org/account/bob/avatar/32' } }, + }, + role: 'REVIEWER', + approved: true, + state: 'approved', + }, + ], + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const status = await getReviewStatus(ORG_OWNER, 'acme', 'repo', 12); + + expect(status.participants[0].avatarUrl).toBe('https://bitbucket.org/account/bob/avatar/32'); + }); +}); 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..510df739a3 --- /dev/null +++ b/apps/web/src/lib/provider-review/bitbucket-read.ts @@ -0,0 +1,1325 @@ +/** + * Bitbucket Cloud pull-request READ layer for the provider review surfaces. + * + * Every function resolves credentials through bitbucket-authorization first, + * so the workspace identity and the workspace access token are always + * server-derived, and returns the shared s1 DTOs so a provider difference + * never leaks past this module. Bitbucket paginates with opaque `next` URLs: + * cursors are encoded server-side, every page re-authorizes against the org + * integration, and a cursor — or a provider next URL — can never change the + * workspace or repository identity a request reads. + */ +import 'server-only'; + +import { z } from 'zod'; +import type { + ProviderPrChecksResult, + ProviderPrFile, + ProviderPrFilesPage, + ProviderPrInboxItem, + ProviderPrInboxPage, + ProviderPrMergeBlockedReason, + ProviderPrMergeState, + ProviderPrSummary, + ProviderPrThread, +} from '@kilocode/app-shared/provider-review'; +import { + authorizeRepository, + authorizeWorkspace, + classifyBitbucketError, + BitbucketApiStatusError, + BitbucketReviewError, + type BitbucketRepositoryAccess, + type BitbucketReviewOwner, +} from './bitbucket-authorization'; + +const BITBUCKET_API_ORIGIN = 'https://api.bitbucket.org'; +const BITBUCKET_PAGE_SIZE = 50; +const BITBUCKET_REQUEST_TIMEOUT_MS = 30_000; +/** Same response cap the GitLab read layer applies, so one response cannot stream unbounded bytes. */ +const MAX_BITBUCKET_RESPONSE_BYTES = 10 * 1024 * 1024; +/** + * The counts folded into the PR summary come from the diffstat; cap the pages + * so one detail load can never fan out into an unbounded crawl on a huge pull + * request (same rule as the GitLab detail load). + */ +const MAX_SUMMARY_DIFFSTAT_PAGES = 3; +/** The merge gate checks at most this many pages of the latest builds. */ +const MAX_BUILD_PAGES = 3; +/** The inbox enumerates at most this many pages of this size of workspace repositories. */ +const INBOX_REPOSITORY_PAGE_SIZE = 100; +const INBOX_REPOSITORY_PAGES = 3; +/** How many repository PR collections the inbox fetches at once. */ +const INBOX_REPOSITORY_CONCURRENCY = 8; +/** + * The provider pages one inbox page may walk per repository: inbox page N + * refetches provider pages 1..N so the sorted windows continue each other, + * and this bound keeps that fan-out capped (a bounded inbox beats an + * unbounded crawl). + */ +const MAX_INBOX_PROVIDER_PAGES = 10; +/** + * The task-collection page bound for the discussion task walk: the same + * bounded walk the write layer's thread resolution uses, so one discussion + * load can never crawl an unbounded collection. + */ +const MAX_TASK_COLLECTION_PAGES = 10; + +const BitbucketUserSchema = z.object({ + uuid: z.string().min(1), + display_name: z.string().nullable().optional(), + nickname: z.string().nullable().optional(), + links: z + .object({ avatar: z.object({ href: z.string() }).nullable().optional() }) + .nullable() + .optional(), +}); + +const BitbucketCommitSideSchema = z.object({ + branch: z.object({ name: z.string().nullable().optional() }).nullable().optional(), + commit: z + .object({ hash: z.string().min(1) }) + .nullable() + .optional(), + repository: z + .object({ + full_name: z.string().min(3).optional(), + uuid: z.string().min(1).optional(), + }) + .nullable() + .optional(), +}); + +/** The PR detail JSON carries more fields than the mapped subset. */ +const BitbucketPullRequestDetailSchema = z.object({ + id: z.number(), + title: z.string(), + state: z.enum(['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED']), + draft: z.boolean().nullable().optional(), + summary: z.object({ raw: z.string().nullable().optional() }).nullable().optional(), + author: BitbucketUserSchema.nullable().optional(), + source: BitbucketCommitSideSchema.nullable().optional(), + destination: BitbucketCommitSideSchema.nullable().optional(), + task_count: z.number().nullable().optional(), + created_on: z.string().nullable().optional(), + updated_on: z.string().nullable().optional(), + links: z + .object({ html: z.object({ href: z.string() }).nullable().optional() }) + .nullable() + .optional(), + participants: z.array(z.unknown()).nullable().optional(), +}); + +type BitbucketPullRequestDetail = z.infer; + +const BitbucketDiffstatEntrySchema = z.object({ + status: z.string().nullable().optional(), + lines_added: z.number().nullable().optional(), + lines_removed: z.number().nullable().optional(), + old: z + .object({ + path: z.string().nullable().optional(), + escaped_path: z.string().nullable().optional(), + }) + .nullable() + .optional(), + new: z + .object({ + path: z.string().nullable().optional(), + escaped_path: z.string().nullable().optional(), + }) + .nullable() + .optional(), +}); + +const BitbucketCommentSchema = z.object({ + id: z.number(), + parent: z.object({ id: z.number() }).nullable().optional(), + content: z.object({ raw: z.string().nullable().optional() }).nullable().optional(), + inline: z + .object({ + path: z.string().nullable().optional(), + from: z.number().nullable().optional(), + to: z.number().nullable().optional(), + }) + .nullable() + .optional(), + created_on: z.string().nullable().optional(), + deleted: z.boolean().nullable().optional(), + user: BitbucketUserSchema.nullable().optional(), +}); + +const BitbucketBuildStatusSchema = z.object({ + state: z.string(), + key: z.string().nullable().optional(), + name: z.string().nullable().optional(), + url: z.string().nullable().optional(), + links: z + .object({ status: z.object({ href: z.string() }).nullable().optional() }) + .nullable() + .optional(), +}); + +const BitbucketTaskSchema = z.object({ + id: z.number(), + resolved_on: z.string().nullable().optional(), + comment: z.object({ id: z.number() }).nullable().optional(), +}); + +const BitbucketBranchRestrictionSchema = z.object({ + kind: z.string(), + value: z.union([z.number(), z.string(), z.null()]).nullable().optional(), +}); + +const BitbucketParticipantSchema = z.object({ + user: BitbucketUserSchema.nullable().optional(), + role: z.string().nullable().optional(), + approved: z.boolean().nullable().optional(), + state: z.string().nullable().optional(), +}); + +const BitbucketInboxPullRequestSchema = z.object({ + id: z.number(), + title: z.string(), + state: z.enum(['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED']), + draft: z.boolean().nullable().optional(), + author: BitbucketUserSchema.nullable().optional(), + updated_on: z.string().nullable().optional(), + source: BitbucketCommitSideSchema.nullable().optional(), + destination: BitbucketCommitSideSchema.nullable().optional(), +}); + +const BitbucketPageSchema = z.object({ + values: z.array(z.unknown()).default([]), + next: z.string().nullable().optional(), +}); + +function mapPullRequestState(state: string): ProviderPrSummary['state'] { + if (state === 'MERGED') return 'merged'; + if (state === 'OPEN') return 'open'; + return 'closed'; +} + +function mapUser(user: z.infer | null | undefined) { + if (!user) return null; + const login = user.nickname ?? user.display_name ?? ''; + if (!login) return null; + return { login, avatarUrl: user.links?.avatar?.href ?? null }; +} + +/** + * One JSON request against api.bitbucket.org. The origin is fixed (Bitbucket + * Cloud is SaaS-only — there is no self-managed URL to resolve) and the + * bearer token is the server-derived workspace access token. Only the status + * survives a provider failure, so no response body can leak into the error. + */ +export async function requestBitbucketJson( + access: { accessToken: string }, + path: string, + request: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + query?: Record; + body?: unknown; + } = {} +): Promise { + if (!path.startsWith('/2.0/')) { + throw new BitbucketReviewError('bad_request', 'Bitbucket request paths must use the 2.0 API.'); + } + // The path is the full versioned API path; the origin contributes no + // version prefix, so a double `/2.0/2.0/` segment can never be built. + const url = new URL(`${BITBUCKET_API_ORIGIN}${path}`); + for (const [key, value] of Object.entries(request.query ?? {})) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + try { + const text = await fetchBoundedText(url.toString(), { + accessToken: access.accessToken, + method: request.method ?? 'GET', + body: request.body, + }); + if (text === null || text === '') return undefined as T; + return JSON.parse(text) as T; + } catch (error) { + throw classifyBitbucketError(error); + } +} + +/** + * One bearer request with a bounded read: the body is streamed with a cap so + * a hostile response cannot stream unbounded bytes (same rule as the GitLab + * transport). Returns null for a bodyless 204/205/304. + * + * Bitbucket Cloud legitimately answers some GET endpoints with a 30x redirect + * (the pull-request diffstat endpoint redirects onto its `/diffstat/` + * form), so the transport follows redirect responses itself: `fetch` runs + * with `redirect: 'manual'`, and a redirect is re-issued only when it is a + * GET whose `location` resolves back onto the Bitbucket API origin under + * `/2.0/` — the bearer token never leaves that origin. Everything else keeps + * the pre-existing behavior: the bare status is raised and classified. + */ +const BITBUCKET_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const BITBUCKET_MAX_REDIRECT_HOPS = 3; + +function sameApiOriginRedirectTarget(currentUrl: string, location: string): string | null { + try { + const target = new URL(location, currentUrl); + if (target.origin !== BITBUCKET_API_ORIGIN) return null; + if (!target.pathname.startsWith('/2.0/')) return null; + return target.toString(); + } catch { + return null; + } +} + +async function fetchBoundedText( + url: string, + request: { + accessToken: string; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + body?: unknown; + accept?: string; + } +): Promise { + const method = request.method ?? 'GET'; + let requestUrl = url; + for (let hop = 0; hop <= BITBUCKET_MAX_REDIRECT_HOPS; hop += 1) { + const response = await fetch(requestUrl, { + method, + headers: { + Authorization: `Bearer ${request.accessToken}`, + Accept: request.accept ?? 'application/json', + ...(request.body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: request.body !== undefined ? JSON.stringify(request.body) : undefined, + redirect: 'manual', + signal: AbortSignal.timeout(BITBUCKET_REQUEST_TIMEOUT_MS), + }); + if (BITBUCKET_REDIRECT_STATUSES.has(response.status)) { + const location = response.headers.get('location'); + const target = + method === 'GET' && location !== null + ? sameApiOriginRedirectTarget(requestUrl, location) + : null; + if (target !== null && hop < BITBUCKET_MAX_REDIRECT_HOPS) { + requestUrl = target; + continue; + } + throw new BitbucketApiStatusError( + response.status, + `Bitbucket ${method} request failed: ${response.status}` + ); + } + if (!response.ok) { + throw new BitbucketApiStatusError( + response.status, + `Bitbucket ${method} request failed: ${response.status}` + ); + } + if (response.status === 204 || response.status === 205 || response.status === 304) return null; + const reader = response.body?.getReader(); + if (!reader) return ''; + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!(value instanceof Uint8Array)) { + throw new BitbucketReviewError('retryable', 'Bitbucket returned an unexpected response.'); + } + totalBytes += value.byteLength; + if (totalBytes > MAX_BITBUCKET_RESPONSE_BYTES) { + try { + await reader.cancel(); + } catch { + // The bounded read remains failed if cancellation itself fails. + } + throw new BitbucketReviewError( + 'retryable', + 'The Bitbucket response exceeded the size limit.' + ); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const merged = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(merged); + } + // The loop always returns or throws; this line is unreachable. + throw new BitbucketReviewError('retryable', 'Bitbucket returned an unexpected response.'); +} + +/** One raw-text request (the `/src` file endpoint answers plain text). */ +async function requestBitbucketText( + access: { accessToken: string }, + path: string +): Promise { + try { + const text = await fetchBoundedText(`${BITBUCKET_API_ORIGIN}${path}`, { + accessToken: access.accessToken, + accept: '*/*', + }); + return text ?? ''; + } catch (error) { + throw classifyBitbucketError(error); + } +} + +function repositorySegment(repository: { fullName: string }): string { + const [workspace, repoSlug] = repository.fullName.split('/'); + return `${encodeURIComponent(workspace ?? '')}/${encodeURIComponent(repoSlug ?? '')}`; +} + +/** + * A provider `next` URL is followed only when it stays on the Bitbucket API + * origin under `/2.0/`. Anything else ends pagination — a hostile next link + * must never re-target the bearer token. + */ +function validatedNextUrl(value: string | null | undefined): string | null { + if (!value) return null; + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + if ( + url.protocol !== 'https:' || + url.hostname !== 'api.bitbucket.org' || + url.username !== '' || + url.password !== '' || + url.port !== '' || + !url.pathname.startsWith('/2.0/') || + url.hash !== '' + ) { + return null; + } + return url.toString(); +} + +/** + * A page cursor carries the collection identity it was minted for. A cursor + * bound to another collection is ignored (page 1), so a cursor can never + * switch the workspace or repository a request reads. + */ +function encodePageCursor(identity: string, nextUrl: string): string { + return Buffer.from(JSON.stringify({ identity, next: nextUrl })).toString('base64url'); +} + +function decodePageCursor( + cursor: string | undefined, + identity: string +): { followUrl: string | null } { + if (!cursor) return { followUrl: null }; + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { + identity?: unknown; + next?: unknown; + }; + if (typeof parsed.identity !== 'string' || parsed.identity !== identity) { + return { followUrl: null }; + } + if (typeof parsed.next !== 'string') return { followUrl: null }; + return { followUrl: validatedNextUrl(parsed.next) }; + } catch { + return { followUrl: null }; + } +} + +function repositoryPathGuard(repository: BitbucketRepositoryAccess): (pathname: string) => boolean { + const prefix = `/2.0/repositories/${encodeURIComponent(repository.workspace.slug)}/${encodeURIComponent(repository.repository.slug)}/`; + return pathname => pathname.startsWith(prefix); +} + +export { repositoryPathGuard }; + +/** + * One page of any Bitbucket collection. When a cursor carries a validated + * next URL the page is fetched there (with the caller's fresh token); the + * next URL is followed only inside the guarded path space, so a cursor — or + * a provider next link — can never change the collection a request reads. + * Shared with the write layer, which paginates the task collection the same + * way when it resolves a thread. + */ +export async function fetchPage( + access: { accessToken: string }, + basePath: string, + identity: string, + cursor: string | undefined, + pathGuard: (pathname: string) => boolean, + extraQuery: Record = {} +): Promise<{ values: unknown[]; nextCursor: string | null }> { + const page = decodePageCursor(cursor, identity); + let payload: unknown; + if (page.followUrl) { + const followUrl = new URL(page.followUrl); + if (!pathGuard(followUrl.pathname)) return { values: [], nextCursor: null }; + try { + const text = await fetchBoundedText(followUrl.toString(), { + accessToken: access.accessToken, + }); + payload = text ? JSON.parse(text) : {}; + } catch (error) { + if (error instanceof BitbucketReviewError || error instanceof BitbucketApiStatusError) { + throw error; + } + throw classifyBitbucketError(error); + } + } else { + payload = await requestBitbucketJson(access, basePath, { + query: { pagelen: BITBUCKET_PAGE_SIZE, ...extraQuery }, + }); + } + + const parsed = BitbucketPageSchema.safeParse(payload); + if (!parsed.success) { + throw new BitbucketReviewError('retryable', 'Bitbucket returned an unexpected page.'); + } + // validatedNextUrl never throws: a malformed provider next link ends + // pagination instead of failing the page, and a next link outside the + // guarded path space is dropped the same way. + const nextUrl = validatedNextUrl(parsed.data.next ?? null); + const guardedNext = nextUrl && pathGuard(new URL(nextUrl).pathname) ? nextUrl : null; + return { + values: parsed.data.values, + nextCursor: guardedNext ? encodePageCursor(identity, guardedNext) : null, + }; +} + +async function fetchPullRequestDetail( + access: BitbucketRepositoryAccess, + prId: number +): Promise { + const payload = await requestBitbucketJson( + access, + `/2.0/repositories/${repositorySegment(access.repository)}/pullrequests/${prId}` + ); + const parsed = BitbucketPullRequestDetailSchema.safeParse(payload); + if (!parsed.success) { + throw new BitbucketReviewError('retryable', 'Bitbucket returned an unexpected pull request.'); + } + return parsed.data; +} + +function mapDiffstatEntry(entry: z.infer): ProviderPrFile { + const oldPath = entry.old?.escaped_path ?? entry.old?.path ?? null; + const newPath = entry.new?.escaped_path ?? entry.new?.path ?? null; + const path = newPath ?? oldPath ?? ''; + return { + path, + previousPath: oldPath !== null && oldPath !== path ? oldPath : null, + status: entry.status ?? 'modified', + additions: entry.lines_added ?? 0, + deletions: entry.lines_removed ?? 0, + patch: null, + patchMissing: true, + }; +} + +async function fetchDiffstatPage( + access: BitbucketRepositoryAccess, + prId: number, + identity: string, + cursor: string | undefined +): Promise<{ values: z.infer[]; nextCursor: string | null }> { + const page = await fetchPage( + access, + `/2.0/repositories/${repositorySegment(access.repository)}/pullrequests/${prId}/diffstat`, + identity, + cursor, + repositoryPathGuard(access) + ); + const values: z.infer[] = []; + for (const value of page.values) { + const parsed = BitbucketDiffstatEntrySchema.safeParse(value); + if (parsed.success) values.push(parsed.data); + } + return { values, nextCursor: page.nextCursor }; +} + +/** + * The PR as the review screen renders it: detail with `source.commit.hash` as + * the head sha, and change counts folded in from the first diffstat pages. + */ +export async function getPullRequest( + owner: BitbucketReviewOwner, + workspaceSlug: string, + repoSlug: string, + prId: number +): Promise { + const access = await authorizeRepository(owner, workspaceSlug, repoSlug); + try { + const identity = `bitbucket-pr:${access.repository.fullName}#${prId}`; + const detail = await fetchPullRequestDetail(access, prId); + // Counts come from the diffstat; cap the pages so one detail load can + // never fan out into an unbounded crawl on a huge pull request. + const files: z.infer[] = []; + let cursor: string | undefined = undefined; + for (let page = 0; page < MAX_SUMMARY_DIFFSTAT_PAGES; page++) { + const result = await fetchDiffstatPage(access, prId, identity, cursor); + files.push(...result.values); + if (!result.nextCursor) break; + cursor = result.nextCursor; + } + let additions = 0; + let deletions = 0; + for (const file of files) { + const mapped = mapDiffstatEntry(file); + additions += mapped.additions; + deletions += mapped.deletions; + } + + return { + ref: { + platform: 'bitbucket', + workspace: access.workspace.slug, + repoSlug: access.repository.slug, + prId, + }, + title: detail.title, + body: detail.summary?.raw ?? null, + author: mapUser(detail.author ?? null), + state: mapPullRequestState(detail.state), + draft: detail.draft === true, + headRef: detail.source?.branch?.name ?? '', + baseRef: detail.destination?.branch?.name ?? '', + headSha: detail.source?.commit?.hash ?? '', + changedFiles: files.length, + additions, + deletions, + webUrl: detail.links?.html?.href ?? '', + createdAt: detail.created_on ?? detail.updated_on ?? '', + updatedAt: detail.updated_on ?? '', + }; + } catch (error) { + throw classifyBitbucketError(error); + } +} + +/** One page of changed files. `cursor` is the opaque page token from a prior call. */ +export async function listChangedFiles( + owner: BitbucketReviewOwner, + workspaceSlug: string, + repoSlug: string, + prId: number, + cursor?: string +): Promise { + const access = await authorizeRepository(owner, workspaceSlug, repoSlug); + try { + const identity = `bitbucket-diffstat:${access.repository.fullName}#${prId}`; + const page = await fetchDiffstatPage(access, prId, identity, cursor); + return { + files: page.values.map(mapDiffstatEntry), + nextCursor: page.nextCursor, + }; + } catch (error) { + throw classifyBitbucketError(error); + } +} + +export type BitbucketFileLines = { + lines: string[]; + totalLines: number; +}; + +/** + * A 1-based inclusive line window of a file at a commit, for comment context. + * A missing file is a non-retryable not_found. + */ +export async function getFileLines( + owner: BitbucketReviewOwner, + workspaceSlug: string, + repoSlug: string, + ref: string, + path: string, + startLine: number, + endLine: number +): Promise { + const access = await authorizeRepository(owner, workspaceSlug, repoSlug); + try { + if (!/^[0-9a-fA-F]{6,64}$/.test(ref)) { + throw new BitbucketReviewError('bad_request', 'The file ref must be a commit hash.'); + } + const cleanPath = path.replace(/^\/+/, ''); + if (!cleanPath || cleanPath.includes('..')) { + throw new BitbucketReviewError('not_found', 'The file was not found at this commit.'); + } + const encodedPath = cleanPath.split('/').map(encodeURIComponent).join('/'); + const text = await requestBitbucketText( + access, + `/2.0/repositories/${repositorySegment(access.repository)}/src/${encodeURIComponent(ref)}/${encodedPath}` + ); + const allLines = text.split('\n'); + const start = Math.max(1, Math.min(startLine, allLines.length)); + const end = Math.max(start, Math.min(endLine, allLines.length)); + return { lines: allLines.slice(start - 1, end), totalLines: allLines.length }; + } catch (error) { + throw classifyBitbucketError(error); + } +} + +/** + * A discussion thread. Bitbucket threads are top-level comments with replies; + * inline anchors come from the root comment's `inline` block. `taskCount` is + * the number of tasks collected for the thread root, resolved and unresolved + * — Bitbucket comments carry no task count of their own. + */ +export type BitbucketDiscussionThread = ProviderPrThread & { taskCount: number }; + +export type BitbucketDiscussionsPage = { + threads: BitbucketDiscussionThread[]; + nextCursor: string | null; +}; + +/** + * Build threads from one flat page of comments: top-level comments are the + * thread roots, replies attach to their parent. Both the resolved flag and + * the task count come from the task evidence the caller supplies — Bitbucket + * never sends task fields on comments: a thread is resolved when a task + * exists for its root comment and no task on it is unresolved. + */ +function buildThreadsFromComments( + comments: z.infer[], + taskEvidence: { + commentIds: ReadonlySet; + unresolvedCommentIds: ReadonlySet; + taskCounts: ReadonlyMap; + } +): BitbucketDiscussionThread[] { + const roots = comments.filter(comment => !comment.parent && comment.deleted !== true); + const repliesByParent = new Map[]>(); + for (const comment of comments) { + if (comment.parent && comment.deleted !== true) { + const existing = repliesByParent.get(comment.parent.id) ?? []; + existing.push(comment); + repliesByParent.set(comment.parent.id, existing); + } + } + return roots.map(root => { + const inline = root.inline ?? null; + const anchorLine = inline?.to ?? inline?.from ?? null; + const taskCount = taskEvidence.taskCounts.get(root.id) ?? 0; + return { + threadId: String(root.id), + resolved: + taskEvidence.commentIds.has(root.id) && !taskEvidence.unresolvedCommentIds.has(root.id), + path: inline?.path ?? null, + line: anchorLine, + side: inline ? (inline.to != null ? 'RIGHT' : 'LEFT') : null, + comments: [root, ...(repliesByParent.get(root.id) ?? [])].map(comment => ({ + commentId: String(comment.id), + author: mapUser(comment.user), + body: comment.content?.raw ?? '', + createdAt: comment.created_on ?? '', + })), + taskCount, + }; + }); +} + +/** + * Fetch the PR's task collection and fold it into task evidence per + * comment: which comments hold tasks at all, which still hold an unresolved + * task, and how many tasks each holds. The collection is paginated, so the + * walk follows every page up to the same bounded page count the write + * layer's thread resolution uses. A provider that does not expose the + * collection (404) or forbids reading it leaves no task evidence — threads + * then read unresolved and taskless instead of failing the whole discussion + * list. A walk that hits the page bound with pages left unread is treated + * the same way: partial evidence must never claim a resolution or a count + * the unread pages could contradict. + */ +async function fetchTaskEvidence( + access: BitbucketRepositoryAccess, + prId: number +): Promise<{ + commentIds: ReadonlySet; + unresolvedCommentIds: ReadonlySet; + taskCounts: ReadonlyMap; +}> { + const noTaskEvidence = () => ({ + commentIds: new Set(), + unresolvedCommentIds: new Set(), + taskCounts: new Map(), + }); + const { commentIds, unresolvedCommentIds, taskCounts } = noTaskEvidence(); + let cursor: string | undefined = undefined; + let exhausted = true; + try { + for (let pageIndex = 0; pageIndex < MAX_TASK_COLLECTION_PAGES; pageIndex++) { + const page = await fetchPage( + access, + `/2.0/repositories/${repositorySegment(access.repository)}/pullrequests/${prId}/tasks`, + `bitbucket-tasks:${access.repository.fullName}#${prId}`, + cursor, + repositoryPathGuard(access), + { pagelen: 100 } + ); + for (const value of page.values) { + const parsed = BitbucketTaskSchema.safeParse(value); + if (!parsed.success) continue; + const commentId = parsed.data.comment?.id; + if (typeof commentId !== 'number') continue; + commentIds.add(commentId); + if (parsed.data.resolved_on == null) unresolvedCommentIds.add(commentId); + taskCounts.set(commentId, (taskCounts.get(commentId) ?? 0) + 1); + } + if (!page.nextCursor) { + exhausted = true; + break; + } + exhausted = false; + cursor = page.nextCursor; + } + } catch (error) { + if ( + error instanceof BitbucketReviewError && + (error.kind === 'not_found' || error.kind === 'forbidden') + ) { + return noTaskEvidence(); + } + throw error; + } + if (!exhausted) return noTaskEvidence(); + return { commentIds, unresolvedCommentIds, taskCounts }; +} + +/** One page of discussions (threads and replies) with their diff anchors. */ +export async function listDiscussions( + owner: BitbucketReviewOwner, + workspaceSlug: string, + repoSlug: string, + prId: number, + cursor?: string +): Promise { + const access = await authorizeRepository(owner, workspaceSlug, repoSlug); + try { + const identity = `bitbucket-comments:${access.repository.fullName}#${prId}`; + const page = await fetchPage( + access, + `/2.0/repositories/${repositorySegment(access.repository)}/pullrequests/${prId}/comments`, + identity, + cursor, + repositoryPathGuard(access) + ); + const comments: z.infer[] = []; + for (const value of page.values) { + const parsed = BitbucketCommentSchema.safeParse(value); + if (parsed.success) comments.push(parsed.data); + } + const taskEvidence = await fetchTaskEvidence(access, prId); + return { + threads: buildThreadsFromComments(comments, taskEvidence), + nextCursor: page.nextCursor, + }; + } catch (error) { + throw classifyBitbucketError(error); + } +} + +async function fetchBuildStatusesPage( + access: BitbucketRepositoryAccess, + headSha: string, + cursor: string | undefined +): Promise<{ values: z.infer[]; nextCursor: string | null }> { + const page = await fetchPage( + access, + `/2.0/repositories/${repositorySegment(access.repository)}/commit/${encodeURIComponent(headSha)}/statuses`, + `bitbucket-statuses:${access.repository.fullName}#${headSha}`, + cursor, + repositoryPathGuard(access) + ); + const values: z.infer[] = []; + for (const value of page.values) { + const parsed = BitbucketBuildStatusSchema.safeParse(value); + if (parsed.success) values.push(parsed.data); + } + return { values, nextCursor: page.nextCursor }; +} + +const FINISHED_BUILD_STATES = new Set(['SUCCESSFUL', 'FAILED']); + +/** + * The builds running on the PR head commit, as the shared checks DTO. A + * finished build keeps the provider verdict; a running or stopped build reads + * as pending with no conclusion. + */ +export async function listChecks( + owner: BitbucketReviewOwner, + workspaceSlug: string, + repoSlug: string, + prId: number +): Promise { + const access = await authorizeRepository(owner, workspaceSlug, repoSlug); + try { + const detail = await fetchPullRequestDetail(access, prId); + const headSha = detail.source?.commit?.hash; + if (!headSha) return { checks: [] }; + const checks: ProviderPrChecksResult['checks'] = []; + let cursor: string | undefined = undefined; + for (let page = 0; page < MAX_BUILD_PAGES; page++) { + const result = await fetchBuildStatusesPage(access, headSha, cursor); + for (const status of result.values) { + const state = status.state.toUpperCase(); + checks.push({ + name: status.name ?? status.key ?? 'build', + status: FINISHED_BUILD_STATES.has(state) ? 'completed' : 'pending', + conclusion: state === 'SUCCESSFUL' ? 'success' : state === 'FAILED' ? 'failed' : null, + detailsUrl: status.links?.status?.href ?? status.url ?? null, + }); + } + if (!result.nextCursor) break; + cursor = result.nextCursor; + } + return { checks }; + } catch (error) { + throw classifyBitbucketError(error); + } +} + +/** + * Open pull requests across the connected workspace, for the PR Review inbox. + * Each item carries platform, workspace, and repository identity, so the list + * can never navigate into a different provider's repo. + * + * Bitbucket removed the aggregate collections that used to answer this in one + * request (`/2.0/pullrequests?role=REVIEWER` and the workspace-level twin + * both answer "There is no API hosted at this URL" today), and a workspace + * access token cannot resolve its own account (`/2.0/user` answers 403), so + * "reviewer = me" is not reproducible. The inbox therefore lists every open + * PR of the workspace's repositories, newest first. + * + * The fan-out sorts across repositories, so one inbox page cannot map onto a + * single provider page: page N refetches provider pages 1..N of every + * repository (each listing is pinned to `sort=-updated_on`, the inbox's own + * sort key) and serves the sorted window [(N-1)·size, N·size). Refetching + * the earlier provider pages is what keeps the windows from drifting: a + * plain provider-page-N fan-out plus sort-and-trim would silently drop the + * rows page 1's trim left over, and they would never be re-served. + */ +export async function listInbox( + owner: BitbucketReviewOwner, + cursor?: string +): Promise { + const access = await authorizeWorkspace(owner); + try { + const identity = `bitbucket-inbox:${access.workspace.slug}`; + const page = decodeInboxPageCursor(cursor, identity); + if (page > MAX_INBOX_PROVIDER_PAGES) { + // Past the walk bound the pagination ends instead of crawling. + return { items: [], nextCursor: null }; + } + const slugs = await listWorkspaceRepositorySlugs(access, access.workspace.slug); + const values: unknown[] = []; + let deeperProviderPage = false; + for (let providerPage = 1; providerPage <= page; providerPage += 1) { + let lastRequestedPageFull = false; + for (let offset = 0; offset < slugs.length; offset += INBOX_REPOSITORY_CONCURRENCY) { + const batch = slugs.slice(offset, offset + INBOX_REPOSITORY_CONCURRENCY); + const pages = await Promise.all( + batch.map(slug => + requestBitbucketJson( + access, + `/2.0/repositories/${encodeURIComponent(access.workspace.slug)}/${encodeURIComponent(slug)}/pullrequests`, + { + query: { + pagelen: BITBUCKET_PAGE_SIZE, + page: providerPage, + q: 'state="OPEN"', + sort: '-updated_on', + }, + } + ) + ) + ); + for (const payload of pages) { + const parsedPage = BitbucketPageSchema.safeParse(payload); + if (!parsedPage.success) { + throw new BitbucketReviewError('retryable', 'Bitbucket returned an unexpected page.'); + } + values.push(...parsedPage.data.values); + if (parsedPage.data.values.length >= BITBUCKET_PAGE_SIZE) lastRequestedPageFull = true; + } + } + if (providerPage === page) deeperProviderPage = lastRequestedPageFull; + } + const items: ProviderPrInboxItem[] = []; + for (const value of values) { + const parsed = BitbucketInboxPullRequestSchema.safeParse(value); + if (!parsed.success) continue; + const ref = inboxRefFrom(parsed.data, access.workspace.slug); + if (!ref) continue; + items.push({ + ref, + title: parsed.data.title, + author: mapUser(parsed.data.author ?? null), + state: mapPullRequestState(parsed.data.state), + draft: parsed.data.draft === true, + updatedAt: parsed.data.updated_on ?? '', + }); + } + items.sort((left, right) => inboxUpdatedMs(right) - inboxUpdatedMs(left)); + const skip = (page - 1) * BITBUCKET_PAGE_SIZE; + const window = items.slice(skip, skip + BITBUCKET_PAGE_SIZE); + // More follows when a repository still holds a page after the deepest + // one read, or when the sorted aggregate itself already reaches past + // this window. + const hasMore = deeperProviderPage || items.length > skip + window.length; + return { + items: window, + nextCursor: + hasMore && page < MAX_INBOX_PROVIDER_PAGES + ? encodeInboxPageCursor(identity, page + 1) + : null, + }; + } catch (error) { + throw classifyBitbucketError(error); + } +} + +function inboxUpdatedMs(item: ProviderPrInboxItem): number { + const ms = Date.parse(item.updatedAt); + return Number.isNaN(ms) ? 0 : ms; +} + +/** + * The inbox cursor is a plain page counter, not a provider `next` URL: one + * inbox page fans out over the workspace's repositories, so no single next + * link can represent it. The page number is the sorted window index — page N + * serves rows [(N-1)·size, N·size) of the merged newest-first order. A + * cursor minted for another workspace, or in the old next-URL shape, decodes + * to page 1 — a cursor can never switch the workspace a request reads. + */ +function encodeInboxPageCursor(identity: string, page: number): string { + return Buffer.from(JSON.stringify({ identity, page })).toString('base64url'); +} + +function decodeInboxPageCursor(cursor: string | undefined, identity: string): number { + if (!cursor) return 1; + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { + identity?: unknown; + page?: unknown; + }; + if (parsed.identity !== identity || !Number.isInteger(parsed.page)) return 1; + return Math.max(1, parsed.page as number); + } catch { + return 1; + } +} + +/** + * Repository slugs of the workspace, newest enumeration capped: at most + * INBOX_REPOSITORY_PAGES pages of INBOX_REPOSITORY_PAGE_SIZE. A workspace + * larger than the cap shows PRs of the repositories Bitbucket enumerates + * first — a bounded inbox beats an unbounded crawl. + */ +async function listWorkspaceRepositorySlugs( + access: { accessToken: string }, + workspaceSlug: string +): Promise { + const slugs: string[] = []; + for (let repoPage = 1; repoPage <= INBOX_REPOSITORY_PAGES; repoPage += 1) { + const payload = await requestBitbucketJson( + access, + `/2.0/repositories/${encodeURIComponent(workspaceSlug)}`, + { query: { pagelen: INBOX_REPOSITORY_PAGE_SIZE, page: repoPage } } + ); + const parsed = z + .object({ + values: z.array(z.object({ slug: z.string().min(1).nullable().optional() })).default([]), + }) + .safeParse(payload); + if (!parsed.success) { + throw new BitbucketReviewError('retryable', 'Bitbucket returned an unexpected page.'); + } + for (const repository of parsed.data.values) { + if (repository.slug) slugs.push(repository.slug); + } + if (parsed.data.values.length < INBOX_REPOSITORY_PAGE_SIZE) break; + } + return slugs; +} + +/** + * The ref of an inbox row: `workspace/repo-slug` from the destination + * repository full name. A row whose identity is unparseable — or outside the + * connected workspace — is skipped: an item without a full identity could + * navigate into the wrong repository. + */ +function inboxRefFrom( + value: z.infer, + workspaceSlug: string +): ProviderPrSummary['ref'] | null { + const fullName = + value.destination?.repository?.full_name ?? value.source?.repository?.full_name ?? ''; + const segments = fullName.split('/'); + if (segments.length !== 2) return null; + const [rowWorkspace, rowRepoSlug] = segments; + if (rowWorkspace.toLowerCase() !== workspaceSlug.toLowerCase() || !rowRepoSlug) return null; + return { + platform: 'bitbucket', + workspace: rowWorkspace, + repoSlug: rowRepoSlug, + prId: value.id, + }; +} + +/** + * The merge gate: the PR's own state (open, draft, unresolved tasks) plus the + * repository's merge checks (branch restrictions, where the token can reach + * them) → `blockedReasons[]` in provider wording. Reviewer approvals come + * from the PR's participants. + */ +export async function getMergeRestrictions( + owner: BitbucketReviewOwner, + workspaceSlug: string, + repoSlug: string, + prId: number +): Promise { + const access = await authorizeRepository(owner, workspaceSlug, repoSlug); + try { + const detail = await fetchPullRequestDetail(access, prId); + + const participants: z.infer[] = []; + for (const value of detail.participants ?? []) { + const parsed = BitbucketParticipantSchema.safeParse(value); + if (parsed.success) participants.push(parsed.data); + } + + // Repository merge checks: a workspace access token without the + // administration scope may not read branch restrictions — absent + // restrictions mean no visible merge gate, not a missing repository. + const restrictions: z.infer[] = []; + try { + const page = await fetchPage( + access, + `/2.0/repositories/${repositorySegment(access.repository)}/branch-restrictions`, + `bitbucket-restrictions:${access.repository.fullName}`, + undefined, + repositoryPathGuard(access), + { pagelen: 100 } + ); + for (const value of page.values) { + const parsed = BitbucketBranchRestrictionSchema.safeParse(value); + if (parsed.success) restrictions.push(parsed.data); + } + } catch (error) { + if ( + !(error instanceof BitbucketReviewError) || + (error.kind !== 'forbidden' && error.kind !== 'not_found') + ) { + throw error; + } + } + + const approvalsRequired = readRestrictionNumber(restrictions, 'require_approvals_to_merge'); + const buildsMustPass = hasRestriction(restrictions, 'require_passing_builds_to_merge'); + // The provider's own conflict verdict: Bitbucket's pull-request detail + // carries no merge state, so the documented file-conflicts endpoint + // answers whether the branches would clash on merge. + const conflicts = await fileConflictsExist(access, detail); + + const blockedReasons: ProviderPrMergeBlockedReason[] = []; + if (detail.state !== 'OPEN') { + blockedReasons.push({ + code: 'other', + message: 'Only open pull requests can be merged.', + }); + } + if (detail.draft === true) { + blockedReasons.push({ code: 'draft', message: 'The pull request is still a draft.' }); + } + if (conflicts) { + blockedReasons.push({ + code: 'conflicts', + message: 'The pull request has conflicts that must be resolved.', + }); + } + // Unresolved tasks always gate the merge from the PR's own task_count: + // the restriction list is often unreadable or unconfigured, so it must + // never decide whether the provider counts tasks. + if ((detail.task_count ?? 0) > 0) { + blockedReasons.push({ + code: 'other', + message: 'Resolve all tasks before merging.', + }); + } + if (approvalsRequired > 0) { + const approvedCount = participants.filter( + participant => participant.approved === true + ).length; + const approvalsLeft = Math.max(0, approvalsRequired - approvedCount); + if (approvalsLeft > 0) { + blockedReasons.push({ + code: 'required_approvals', + message: `${approvalsLeft} more approval${approvalsLeft === 1 ? '' : 's'} required.`, + }); + } + } + if (buildsMustPass && detail.state === 'OPEN') { + const buildState = await latestBuildStateFor(access, detail.source?.commit?.hash ?? ''); + if (buildState === 'failed') { + blockedReasons.push({ + code: 'failing_pipeline', + message: 'The build on the latest commit failed.', + }); + } else if (buildState !== 'success') { + blockedReasons.push({ + code: 'pending_pipeline', + message: + buildState === 'none' + ? 'No build was found for the latest commit.' + : 'The builds on the latest commit have not finished yet.', + }); + } + } + + return { + canMerge: detail.state === 'OPEN' && blockedReasons.length === 0, + approvalsRequired, + pipelineMustSucceed: buildsMustPass, + conflicts, + blockedReasons, + }; + } catch (error) { + throw classifyBitbucketError(error); + } +} + +function hasRestriction( + restrictions: z.infer[], + kind: string +): boolean { + return restrictions.some(restriction => restriction.kind === kind); +} + +/** + * Whether the provider reports merge conflicts for the pull request's + * `source..destination` commit range. The pull-request detail carries no + * merge state of its own — the documented conflict surface is the + * file-conflicts endpoint. A range the provider cannot answer (an endpoint + * the token cannot read, commits missing from the detail) reports no + * conflict: the merge attempt itself stays the final arbiter. + */ +async function fileConflictsExist( + access: BitbucketRepositoryAccess, + detail: BitbucketPullRequestDetail +): Promise { + const sourceHash = detail.source?.commit?.hash ?? ''; + const destinationHash = detail.destination?.commit?.hash ?? ''; + // Commit hashes are hex; anything else never builds the range spec. + if (!/^[0-9a-f]+$/i.test(sourceHash) || !/^[0-9a-f]+$/i.test(destinationHash)) { + return false; + } + try { + const page = await fetchPage( + access, + `/2.0/repositories/${repositorySegment(access.repository)}/file-conflicts/${encodeURIComponent( + `${sourceHash}..${destinationHash}` + )}`, + `bitbucket-conflicts:${access.repository.fullName}`, + undefined, + repositoryPathGuard(access), + { pagelen: 100 } + ); + return page.values.length > 0; + } catch (error) { + // A workspace access token without the repository scope may not read + // file conflicts — absent visibility means no visible conflict gate, not + // a missing pull request. + if ( + error instanceof BitbucketReviewError && + (error.kind === 'forbidden' || error.kind === 'not_found') + ) { + return false; + } + throw error; + } +} + +function readRestrictionNumber( + restrictions: z.infer[], + kind: string +): number { + const restriction = restrictions.find(candidate => candidate.kind === kind); + const value = typeof restriction?.value === 'number' ? restriction.value : 0; + return Number.isInteger(value) && value > 0 ? value : 0; +} + +async function latestBuildStateFor( + access: BitbucketRepositoryAccess, + headSha: string +): Promise<'success' | 'failed' | 'pending' | 'none'> { + if (!headSha) return 'none'; + let sawSuccess = false; + let sawPending = false; + let cursor: string | undefined = undefined; + for (let page = 0; page < MAX_BUILD_PAGES; page++) { + const result = await fetchBuildStatusesPage(access, headSha, cursor); + for (const status of result.values) { + const state = status.state.toUpperCase(); + // One failed build blocks the merge even when another build succeeded; + // a running build keeps the gate pending until every build finished. + if (state === 'FAILED') return 'failed'; + if (state === 'SUCCESSFUL') sawSuccess = true; + else sawPending = true; + } + if (!result.nextCursor) break; + cursor = result.nextCursor; + } + if (sawPending) return 'pending'; + return sawSuccess ? 'success' : 'none'; +} + +export type BitbucketReviewStatusParticipant = { + login: string; + avatarUrl: string | null; + /** Whether the participant has approved the pull request. */ + approved: boolean; + /** True when the participant holds the REVIEWER role. */ + reviewer: boolean; +}; + +export type BitbucketReviewStatus = { + participants: BitbucketReviewStatusParticipant[]; +}; + +/** + * The review status of one PR: the provider's participants with their + * approval state and REVIEWER role, straight from the PR detail. + */ +export async function getReviewStatus( + owner: BitbucketReviewOwner, + workspaceSlug: string, + repoSlug: string, + prId: number +): Promise { + const access = await authorizeRepository(owner, workspaceSlug, repoSlug); + try { + const detail = await fetchPullRequestDetail(access, prId); + const participants: BitbucketReviewStatusParticipant[] = []; + for (const value of detail.participants ?? []) { + const parsed = BitbucketParticipantSchema.safeParse(value); + if (!parsed.success) continue; + const user = mapUser(parsed.data.user ?? null); + if (!user) continue; + participants.push({ + login: user.login, + avatarUrl: user.avatarUrl, + approved: parsed.data.approved === true, + reviewer: parsed.data.role === 'REVIEWER', + }); + } + return { participants }; + } catch (error) { + throw classifyBitbucketError(error); + } +} diff --git a/apps/web/src/lib/provider-review/bitbucket-write.test.ts b/apps/web/src/lib/provider-review/bitbucket-write.test.ts new file mode 100644 index 0000000000..eb574ddd8f --- /dev/null +++ b/apps/web/src/lib/provider-review/bitbucket-write.test.ts @@ -0,0 +1,992 @@ +import { describe, expect, it, beforeEach, afterEach } from '@jest/globals'; +import { + addComment, + BITBUCKET_AUTO_MERGE_UNSUPPORTED_REASON, + BITBUCKET_PR_REVIEW_CAPABILITIES, + BITBUCKET_REACTIONS_UNSUPPORTED_REASON, + BITBUCKET_STALE_HEAD_REASON, + BITBUCKET_THREAD_RESOLUTION_UNSUPPORTED_REASON, + mergePullRequest, + replyToComment, + resolveThread, + submitReview, +} from './bitbucket-write'; +import { BitbucketReviewError } from './bitbucket-authorization'; + +const mockGetBitbucketWorkspaceAccessTokenStatus = jest.fn(); +const mockReadCachedRepositories = jest.fn(); + +jest.mock('@/lib/integrations/platforms/bitbucket/workspace-access-token-repository-cache', () => ({ + getBitbucketWorkspaceAccessTokenStatus: (...args: unknown[]) => + mockGetBitbucketWorkspaceAccessTokenStatus(...args), + readCachedBitbucketWorkspaceAccessTokenRepositories: (input: unknown) => + mockReadCachedRepositories(input), +})); + +jest.mock('@/lib/config.server', () => ({ + GIT_TOKEN_SERVICE_API_URL: 'https://token-service.example.com', +})); + +jest.mock('@/lib/tokens', () => ({ + generateInternalServiceToken: jest.fn(() => 'svc-mock-token'), + TOKEN_EXPIRY: { fiveMinutes: 300 }, +})); + +jest.mock('@/lib/utils.server', () => ({ + logExceptInTest: () => {}, + warnExceptInTest: () => {}, +})); + +const ORG_OWNER = { + type: 'organization' as const, + organizationId: 'org_1', + userId: 'user_1', +}; + +const WORKSPACE = { + uuid: '12345678-1234-1234-1234-123456789012', + slug: 'acme', +}; + +const HEAD_SHA = 'abc123def4567890'; + +const openPr = { + id: 12, + state: 'OPEN', + source: { commit: { hash: HEAD_SHA } }, +}; + +let fetchMock: jest.Mock; + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +/** Await a rejection and return it typed, without a success-branch union. */ +async function captureRejection(promise: Promise): Promise { + try { + await promise; + } catch (reason) { + return reason as BitbucketReviewError; + } + throw new Error('Expected the call to reject.'); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockGetBitbucketWorkspaceAccessTokenStatus.mockResolvedValue({ + status: 'connected', + integrationId: 'intg_1', + workspace: { ...WORKSPACE, displayName: 'Acme' }, + }); + mockReadCachedRepositories.mockResolvedValue({ + status: 'available', + repositories: [ + { + id: '87654321-4321-4321-4321-210987654321', + workspaceUuid: WORKSPACE.uuid, + name: 'repo', + fullName: 'acme/repo', + private: true, + defaultBranch: 'main', + }, + ], + syncedAt: '2026-09-06T00:00:00.000Z', + }); + fetchMock = jest.fn(); + fetchMock.mockImplementation(async (url: string | URL) => { + const parsed = new URL(url.toString()); + if (url.toString().includes('token-service.example.com')) { + return jsonResponse({ + status: 'available', + token: 'at-mock-token', + workspace: WORKSPACE, + }); + } + if (parsed.pathname.endsWith('/pullrequests/12')) return jsonResponse(openPr); + if (parsed.pathname.endsWith('/tasks')) { + return jsonResponse({ + pagelen: 100, + values: [{ id: 7, resolved_on: null, comment: { id: 101 } }], + next: null, + }); + } + if (parsed.pathname.endsWith('/comments/101')) { + // Bitbucket never sends task_count on comments; the write layer must + // decide from the task collection alone. + return jsonResponse({ id: 101 }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +function bitbucketCalls(): Array<{ url: URL; init: Record }> { + return fetchMock.mock.calls + .map(call => ({ + url: new URL(String(call[0])), + init: (call[1] ?? {}) as Record, + })) + .filter(call => call.url.hostname === 'api.bitbucket.org'); +} + +describe('addComment', () => { + it('posts the raw content to the pull request comments collection', async () => { + const result = await addComment({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + body: 'A review comment', + }); + + expect(result).toEqual({ done: true, replayed: false }); + const calls = bitbucketCalls(); + const post = calls.find(call => call.init.method === 'POST'); + expect(post?.url.pathname).toBe('/2.0/repositories/acme/repo/pullrequests/12/comments'); + expect(JSON.parse(String(post?.init.body))).toEqual({ + content: { raw: 'A review comment' }, + }); + }); + + it('a RIGHT anchor posts an inline comment anchored on the destination line', async () => { + await addComment({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + body: 'Inline on the new side', + anchor: { path: 'src/deploy.ts', side: 'RIGHT', line: 42 }, + }); + + const post = bitbucketCalls().find(call => call.init.method === 'POST'); + expect(JSON.parse(String(post?.init.body))).toEqual({ + content: { raw: 'Inline on the new side' }, + inline: { path: 'src/deploy.ts', to: 42 }, + }); + }); + + it('a LEFT anchor posts an inline comment anchored on the source line', async () => { + await addComment({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + body: 'Inline on the old side', + anchor: { path: 'src/deploy.ts', side: 'LEFT', line: 7 }, + }); + + const post = bitbucketCalls().find(call => call.init.method === 'POST'); + expect(JSON.parse(String(post?.init.body))).toEqual({ + content: { raw: 'Inline on the old side' }, + inline: { path: 'src/deploy.ts', from: 7 }, + }); + }); + + it('a RIGHT startLine range anchors the destination line, never an unrelated source line', async () => { + await addComment({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + body: 'This block', + anchor: { path: 'src/deploy.ts', side: 'RIGHT', line: 20, startLine: 10 }, + }); + + const post = bitbucketCalls().find(call => call.init.method === 'POST'); + // Bitbucket's from/to are source-side and destination-side line numbers, + // not a one-sided range: `from: startLine` would anchor an unrelated old + // line. The range anchors its end line on the tapped (destination) side. + expect(JSON.parse(String(post?.init.body))).toEqual({ + content: { raw: 'This block' }, + inline: { path: 'src/deploy.ts', to: 20 }, + }); + }); + + it('a LEFT startLine range anchors the source line, never an invented new-side line', async () => { + await addComment({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + body: 'This block', + anchor: { path: 'src/deploy.ts', side: 'LEFT', line: 20, startLine: 10 }, + }); + + const post = bitbucketCalls().find(call => call.init.method === 'POST'); + expect(JSON.parse(String(post?.init.body))).toEqual({ + content: { raw: 'This block' }, + inline: { path: 'src/deploy.ts', from: 20 }, + }); + }); + + it('classifies a provider 400 on an anchored comment as bad_request', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ + status: 'available', + token: 'at-mock-token', + workspace: WORKSPACE, + }); + } + return new Response(null, { status: 400 }); + }); + + const error = await captureRejection( + addComment({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + body: 'x', + anchor: { path: 'src/deploy.ts', side: 'RIGHT', line: 999_999 }, + }) + ); + + expect(error.kind).toBe('bad_request'); + expect(error.retryable).toBe(false); + }); + + it('maps a provider 403 to non-retryable forbidden', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ + status: 'available', + token: 'at-mock-token', + workspace: WORKSPACE, + }); + } + return new Response(null, { status: 403 }); + }); + + const error = await captureRejection( + addComment({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + body: 'Nope', + }) + ); + + expect(error).toBeInstanceOf(BitbucketReviewError); + expect(error.kind).toBe('forbidden'); + expect(error.retryable).toBe(false); + }); +}); + +describe('replyToComment', () => { + it('posts a reply carrying the parent comment id', async () => { + const result = await replyToComment({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + commentId: '101', + body: 'A reply', + }); + + expect(result).toEqual({ done: true, replayed: false }); + const calls = bitbucketCalls(); + const post = calls.find(call => call.init.method === 'POST'); + expect(post?.url.pathname).toBe('/2.0/repositories/acme/repo/pullrequests/12/comments'); + expect(JSON.parse(String(post?.init.body))).toEqual({ + content: { raw: 'A reply' }, + parent: { id: 101 }, + }); + }); + + it('refuses a non-numeric comment id as bad_request without any provider call', async () => { + const error = await captureRejection( + replyToComment({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + commentId: 'not-a-number', + body: 'A reply', + }) + ); + + expect(error.kind).toBe('bad_request'); + expect(bitbucketCalls()).toEqual([]); + }); +}); + +describe('submitReview', () => { + it('maps approve to the participants state approved for the connected identity', async () => { + const result = await submitReview({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + event: 'approve', + }); + + expect(result).toEqual({ done: true, replayed: false }); + const put = bitbucketCalls().find( + call => call.init.method === 'PUT' && call.url.pathname.includes('/participants/') + ); + // The participant id is the server-derived workspace uuid: a workspace + // access token cannot resolve an account itself (/2.0/user answers 403). + expect(put?.url.pathname).toBe( + `/2.0/repositories/acme/repo/pullrequests/12/participants/${encodeURIComponent(WORKSPACE.uuid)}` + ); + expect(JSON.parse(String(put?.init.body))).toEqual({ state: 'approved' }); + expect(bitbucketCalls().some(call => call.url.pathname === '/2.0/user')).toBe(false); + }); + + it('maps request_changes to participants state changes_requested', async () => { + const result = await submitReview({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + event: 'request_changes', + }); + + expect(result).toEqual({ done: true, replayed: false }); + const put = bitbucketCalls().find( + call => call.init.method === 'PUT' && call.url.pathname.includes('/participants/') + ); + expect(JSON.parse(String(put?.init.body))).toEqual({ + state: 'changes_requested', + }); + }); + + it('maps comment to clearing the own approval state and posts the body', async () => { + const result = await submitReview({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + event: 'comment', + body: 'Read this first', + }); + + expect(result).toEqual({ done: true, replayed: false }); + const put = bitbucketCalls().find( + call => call.init.method === 'PUT' && call.url.pathname.includes('/participants/') + ); + expect(JSON.parse(String(put?.init.body))).toEqual({ state: null }); + const post = bitbucketCalls().find(call => call.init.method === 'POST'); + expect(JSON.parse(String(post?.init.body))).toEqual({ + content: { raw: 'Read this first' }, + }); + }); + + it('refuses a comment review without a body before any provider call', async () => { + const error = await captureRejection( + submitReview({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + event: 'comment', + }) + ); + + expect(error.kind).toBe('bad_request'); + expect(bitbucketCalls()).toEqual([]); + }); + + it('posts every inline comment before the review state and the summary comment', async () => { + const result = await submitReview({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + event: 'approve', + body: 'LGTM', + comments: [ + { path: 'a.ts', side: 'RIGHT', line: 3, body: 'first inline' }, + { + path: 'b.ts', + side: 'LEFT', + line: 9, + startLine: 4, + body: 'second inline', + }, + ], + }); + + expect(result).toEqual({ done: true, replayed: false }); + const effects = bitbucketCalls().filter( + call => call.init.method === 'POST' || call.init.method === 'PUT' + ); + expect(effects.map(call => `${String(call.init.method)} ${call.url.pathname}`)).toEqual([ + 'POST /2.0/repositories/acme/repo/pullrequests/12/comments', + 'POST /2.0/repositories/acme/repo/pullrequests/12/comments', + `PUT /2.0/repositories/acme/repo/pullrequests/12/participants/${encodeURIComponent(WORKSPACE.uuid)}`, + 'POST /2.0/repositories/acme/repo/pullrequests/12/comments', + ]); + expect(JSON.parse(String(effects[0].init.body))).toEqual({ + content: { raw: 'first inline' }, + inline: { path: 'a.ts', to: 3 }, + }); + expect(JSON.parse(String(effects[1].init.body))).toEqual({ + content: { raw: 'second inline' }, + // A LEFT range anchors its end line on the source side. + inline: { path: 'b.ts', from: 9 }, + }); + expect(JSON.parse(String(effects[3].init.body))).toEqual({ + content: { raw: 'LGTM' }, + }); + }); + + it('a comment event with a batch and no body still posts the inline comments and clears approval', async () => { + const result = await submitReview({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + event: 'comment', + comments: [{ path: 'a.ts', side: 'RIGHT', line: 3, body: 'inline only' }], + }); + + expect(result).toEqual({ done: true, replayed: false }); + const effects = bitbucketCalls().filter( + call => call.init.method === 'POST' || call.init.method === 'PUT' + ); + expect(effects).toHaveLength(2); + expect(JSON.parse(String(effects[0].init.body))).toEqual({ + content: { raw: 'inline only' }, + inline: { path: 'a.ts', to: 3 }, + }); + expect(JSON.parse(String(effects[1].init.body))).toEqual({ state: null }); + }); + + it('a mid-batch rejection after a committed comment reports the ambiguous retryable kind', async () => { + let posts = 0; + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ + status: 'available', + token: 'at-mock-token', + workspace: WORKSPACE, + }); + } + const parsed = new URL(full); + if (parsed.pathname.endsWith('/comments')) { + posts += 1; + return posts === 1 + ? jsonResponse({ id: 101 }) + : jsonResponse({ error: { message: 'inline position invalid' } }, 400); + } + return jsonResponse({}); + }); + + const error = await captureRejection( + submitReview({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + event: 'approve', + body: 'LGTM', + comments: [ + { path: 'a.ts', side: 'RIGHT', line: 3, body: 'first' }, + { path: 'b.ts', side: 'RIGHT', line: 4, body: 'outside' }, + ], + }) + ); + + // The first inline comment already committed: a deterministic + // bad_request would settle the ledger row failed, and the client's + // key-rotating retry would re-post that comment as a duplicate. The + // retryable kind keeps the row reconcile_pending instead. + expect(error.kind).toBe('retryable'); + expect(error.retryable).toBe(true); + // The failure stops the batch: no participants write, no summary comment. + expect(bitbucketCalls().some(call => call.init.method === 'PUT')).toBe(false); + expect(posts).toBe(2); + }); + + it('a rejection on the first comment, with nothing committed, keeps the deterministic bad_request', async () => { + let posts = 0; + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ + status: 'available', + token: 'at-mock-token', + workspace: WORKSPACE, + }); + } + const parsed = new URL(full); + if (parsed.pathname.endsWith('/comments')) { + posts += 1; + return jsonResponse({ error: { message: 'inline position invalid' } }, 400); + } + return jsonResponse({}); + }); + + const error = await captureRejection( + submitReview({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + event: 'approve', + body: 'LGTM', + comments: [ + { path: 'a.ts', side: 'RIGHT', line: 999_999, body: 'outside' }, + { path: 'b.ts', side: 'RIGHT', line: 4, body: 'second' }, + ], + }) + ); + + expect(error.kind).toBe('bad_request'); + expect(error.retryable).toBe(false); + // The batch stops at the refused comment: the second never posts. + expect(posts).toBe(1); + }); + + it('a participants-write rejection after the whole batch committed is a partial apply too', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ + status: 'available', + token: 'at-mock-token', + workspace: WORKSPACE, + }); + } + const parsed = new URL(full); + if (parsed.pathname.includes('/participants/')) { + return jsonResponse({ error: { message: 'forbidden' } }, 403); + } + return jsonResponse({ id: 101 }); + }); + + const error = await captureRejection( + submitReview({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + event: 'approve', + body: 'LGTM', + comments: [{ path: 'a.ts', side: 'RIGHT', line: 3, body: 'first' }], + }) + ); + + // The inline comment committed before the review state was refused: a + // failed settle would let the retry re-post it as a duplicate. + expect(error.kind).toBe('retryable'); + expect(error.retryable).toBe(true); + // The summary comment never posts. + expect( + bitbucketCalls().filter(call => String(call.url.pathname).endsWith('/comments')) + ).toHaveLength(1); + }); +}); + +describe('resolveThread', () => { + it('resolves the comment task when one exists', async () => { + const result = await resolveThread({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + threadId: '101', + }); + + expect(result).toEqual({ done: true, replayed: false }); + const put = bitbucketCalls().find(call => call.init.method === 'PUT'); + expect(put?.url.pathname).toBe('/2.0/repositories/acme/repo/pullrequests/12/tasks/7'); + expect(JSON.parse(String(put?.init.body))).toEqual({ resolved: true }); + }); + + it('refuses a thread without a task with the capability reason', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ + status: 'available', + token: 'at-mock-token', + workspace: WORKSPACE, + }); + } + const parsed = new URL(full); + if (parsed.pathname.endsWith('/comments/101')) return jsonResponse({ id: 101 }); + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const error = await captureRejection( + resolveThread({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + threadId: '101', + }) + ); + + expect(error.kind).toBe('bad_request'); + expect(error.message).toBe(BITBUCKET_THREAD_RESOLUTION_UNSUPPORTED_REASON); + }); + + it('refuses a thread whose tasks all belong to other comments with the capability reason', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ + status: 'available', + token: 'at-mock-token', + workspace: WORKSPACE, + }); + } + const parsed = new URL(full); + if (parsed.pathname.endsWith('/comments/101')) return jsonResponse({ id: 101 }); + if (parsed.pathname.endsWith('/tasks')) { + return jsonResponse({ + pagelen: 100, + values: [{ id: 6, resolved_on: null, comment: { id: 202 } }], + next: null, + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const error = await captureRejection( + resolveThread({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + threadId: '101', + }) + ); + + expect(error.kind).toBe('bad_request'); + expect(error.message).toBe(BITBUCKET_THREAD_RESOLUTION_UNSUPPORTED_REASON); + expect(bitbucketCalls().some(call => call.init.method === 'PUT')).toBe(false); + }); + + it('refuses with the capability reason when the task collection is not exposed', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ + status: 'available', + token: 'at-mock-token', + workspace: WORKSPACE, + }); + } + const parsed = new URL(full); + if (parsed.pathname.endsWith('/comments/101')) return jsonResponse({ id: 101 }); + if (parsed.pathname.endsWith('/tasks')) return new Response(null, { status: 404 }); + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const error = await captureRejection( + resolveThread({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + threadId: '101', + }) + ); + + expect(error.kind).toBe('bad_request'); + expect(error.message).toBe(BITBUCKET_THREAD_RESOLUTION_UNSUPPORTED_REASON); + }); + + it('reports replayed when the task is already resolved', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ + status: 'available', + token: 'at-mock-token', + workspace: WORKSPACE, + }); + } + const parsed = new URL(full); + if (parsed.pathname.endsWith('/comments/101')) return jsonResponse({ id: 101 }); + if (parsed.pathname.endsWith('/tasks')) { + return jsonResponse({ + pagelen: 100, + values: [ + { + id: 7, + resolved_on: '2026-09-06T00:00:00.000Z', + comment: { id: 101 }, + }, + ], + next: null, + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const result = await resolveThread({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + threadId: '101', + }); + + expect(result).toEqual({ done: true, replayed: true }); + expect(bitbucketCalls().some(call => call.init.method === 'PUT')).toBe(false); + }); + + it('follows the paginated task collection and resolves the task on a later page', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ + status: 'available', + token: 'at-mock-token', + workspace: WORKSPACE, + }); + } + const parsed = new URL(full); + if (parsed.pathname.endsWith('/comments/101')) return jsonResponse({ id: 101 }); + if (parsed.pathname.endsWith('/tasks')) { + return parsed.searchParams.get('page') === '2' + ? jsonResponse({ + pagelen: 100, + values: [{ id: 7, resolved_on: null, comment: { id: 101 } }], + next: null, + }) + : jsonResponse({ + pagelen: 100, + values: [{ id: 6, resolved_on: null, comment: { id: 202 } }], + next: 'https://api.bitbucket.org/2.0/repositories/acme/repo/pullrequests/12/tasks?pagelen=100&page=2', + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const result = await resolveThread({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + threadId: '101', + }); + + expect(result).toEqual({ done: true, replayed: false }); + const put = bitbucketCalls().find(call => call.init.method === 'PUT'); + expect(put?.url.pathname).toBe('/2.0/repositories/acme/repo/pullrequests/12/tasks/7'); + expect(JSON.parse(String(put?.init.body))).toEqual({ resolved: true }); + // The collection was followed to page 2 before the task resolved. + expect(bitbucketCalls().filter(call => call.url.pathname.endsWith('/tasks'))).toHaveLength(2); + }); + + it('concludes replayed only after the whole task collection is exhausted', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ + status: 'available', + token: 'at-mock-token', + workspace: WORKSPACE, + }); + } + const parsed = new URL(full); + if (parsed.pathname.endsWith('/comments/101')) return jsonResponse({ id: 101 }); + if (parsed.pathname.endsWith('/tasks')) { + return parsed.searchParams.get('page') === '2' + ? jsonResponse({ + pagelen: 100, + values: [ + { + id: 7, + resolved_on: '2026-09-06T00:00:00.000Z', + comment: { id: 101 }, + }, + ], + next: null, + }) + : jsonResponse({ + pagelen: 100, + values: [{ id: 6, resolved_on: null, comment: { id: 202 } }], + next: 'https://api.bitbucket.org/2.0/repositories/acme/repo/pullrequests/12/tasks?pagelen=100&page=2', + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const result = await resolveThread({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + threadId: '101', + }); + + expect(result).toEqual({ done: true, replayed: true }); + expect(bitbucketCalls().some(call => call.init.method === 'PUT')).toBe(false); + }); + + it('refuses a non-numeric thread id as not_found without a provider call', async () => { + const error = await captureRejection( + resolveThread({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + threadId: 'not-a-number', + }) + ); + + expect(error.kind).toBe('not_found'); + expect(bitbucketCalls()).toEqual([]); + }); +}); + +describe('mergePullRequest', () => { + it('re-fetches the PR, fences the head, and merges the exact revision', async () => { + const result = await mergePullRequest({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + expectedHeadSha: HEAD_SHA, + closeSourceBranch: true, + commitMessage: 'Merged in feature/retry', + }); + + expect(result).toEqual({ done: true, replayed: false }); + const post = bitbucketCalls().find(call => call.init.method === 'POST'); + expect(post?.url.pathname).toBe('/2.0/repositories/acme/repo/pullrequests/12/merge'); + // Bitbucket's merge endpoint names the commit message `message` + // (GitHub's `commit_message` field is silently ignored by Bitbucket). + expect(JSON.parse(String(post?.init.body))).toEqual({ + close_source_branch: true, + message: 'Merged in feature/retry', + }); + }); + + it('refuses a stale revision with the exact reason and never merges', async () => { + const error = await captureRejection( + mergePullRequest({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + expectedHeadSha: 'stale-sha', + }) + ); + + expect(error.kind).toBe('stale_head'); + expect(error.message).toBe(BITBUCKET_STALE_HEAD_REASON); + expect(bitbucketCalls().some(call => call.url.pathname.endsWith('/merge'))).toBe(false); + }); + + it('reports replayed when the PR is already merged', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ + status: 'available', + token: 'at-mock-token', + workspace: WORKSPACE, + }); + } + const parsed = new URL(full); + if (parsed.pathname.endsWith('/pullrequests/12')) { + return jsonResponse({ + id: 12, + state: 'MERGED', + source: { commit: { hash: HEAD_SHA } }, + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const result = await mergePullRequest({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + expectedHeadSha: HEAD_SHA, + }); + + expect(result).toEqual({ done: true, replayed: true }); + expect(bitbucketCalls().some(call => call.init.method === 'POST')).toBe(false); + }); + + it('refuses a closed PR with a non-retryable bad_request', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const full = url.toString(); + if (full.includes('token-service.example.com')) { + return jsonResponse({ + status: 'available', + token: 'at-mock-token', + workspace: WORKSPACE, + }); + } + const parsed = new URL(full); + if (parsed.pathname.endsWith('/pullrequests/12')) { + return jsonResponse({ + id: 12, + state: 'DECLINED', + source: { commit: { hash: HEAD_SHA } }, + }); + } + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const error = await captureRejection( + mergePullRequest({ + owner: ORG_OWNER, + workspace: 'acme', + repoSlug: 'repo', + prId: 12, + expectedHeadSha: HEAD_SHA, + }) + ); + + expect(error.kind).toBe('bad_request'); + expect(error.retryable).toBe(false); + }); +}); + +describe('capabilities and reasons', () => { + it('auto-merge is always unsupported with the provider reason', () => { + expect(BITBUCKET_PR_REVIEW_CAPABILITIES.autoMerge).toEqual({ + supported: false, + reason: 'Bitbucket Cloud does not expose auto-merge in its API', + }); + }); + + it('reactions are unsupported with the provider reason', () => { + expect(BITBUCKET_PR_REVIEW_CAPABILITIES.reactions).toEqual({ + supported: false, + reason: 'Bitbucket Cloud does not expose reactions on pull request comments', + }); + }); + + it('review events include request_changes', () => { + expect(BITBUCKET_PR_REVIEW_CAPABILITIES.reviewEvents).toEqual([ + 'approve', + 'request_changes', + 'comment', + ]); + }); + + it('the exported reasons match the shared capability copy', () => { + expect(BITBUCKET_AUTO_MERGE_UNSUPPORTED_REASON).toBe( + BITBUCKET_PR_REVIEW_CAPABILITIES.autoMerge.reason + ); + expect(BITBUCKET_REACTIONS_UNSUPPORTED_REASON).toBe( + BITBUCKET_PR_REVIEW_CAPABILITIES.reactions.reason + ); + }); +}); diff --git a/apps/web/src/lib/provider-review/bitbucket-write.ts b/apps/web/src/lib/provider-review/bitbucket-write.ts new file mode 100644 index 0000000000..3ec9e0d97f --- /dev/null +++ b/apps/web/src/lib/provider-review/bitbucket-write.ts @@ -0,0 +1,501 @@ +/** + * Bitbucket Cloud pull-request WRITE layer for the provider review surfaces. + * + * Every mutation resolves credentials through bitbucket-authorization (the + * workspace identity and token are server-derived), fences against the + * caller's expected head sha where a revision matters, and returns an + * idempotent-ready `{ done, replayed }` result: `replayed` is true when the + * provider already holds the target state, so the s4 router can run the call + * through the operation ledger without a duplicate effect. `operationKey` is + * accepted for that ledger; this layer performs no ledger writes itself. + */ +import 'server-only'; + +import { z } from 'zod'; +import type { + ProviderReviewCapabilities, + ProviderReviewInlineAnchor, + ProviderReviewInlineComment, +} from '@kilocode/app-shared/provider-review'; +import { BITBUCKET_REVIEW_CAPABILITIES } from '@kilocode/app-shared/provider-review'; +import { + authorizeRepository, + classifyBitbucketError, + BitbucketReviewError, + type BitbucketRepositoryAccess, + type BitbucketReviewOwner, +} from './bitbucket-authorization'; +import { fetchPage, requestBitbucketJson, repositoryPathGuard } from './bitbucket-read'; + +/** + * The Bitbucket capability list for review surfaces. It reuses the shared + * s1 constant: auto-merge and reactions are explicit provider limitations, + * never missing code, and request-changes IS a Bitbucket review event. + */ +export const BITBUCKET_PR_REVIEW_CAPABILITIES: ProviderReviewCapabilities = + BITBUCKET_REVIEW_CAPABILITIES; + +/** + * The exact reason auto-merge is refused: Bitbucket Cloud has no merge-when- + * ready API, so callers show this instead of a fake scheduling affordance. + */ +export const BITBUCKET_AUTO_MERGE_UNSUPPORTED_REASON = + BITBUCKET_REVIEW_CAPABILITIES.autoMerge.reason; + +/** The exact reason reactions are refused on Bitbucket Cloud. */ +export const BITBUCKET_REACTIONS_UNSUPPORTED_REASON = + BITBUCKET_REVIEW_CAPABILITIES.reactions.reason; + +/** + * The exact reason thread resolution is refused when the thread has no task: + * Bitbucket Cloud only exposes resolution through comment tasks. + */ +export const BITBUCKET_THREAD_RESOLUTION_UNSUPPORTED_REASON = + 'Bitbucket Cloud does not expose thread resolution for inline threads without tasks'; + +/** + * The stale-head fence reason, shared with classifyBitbucketStatus so a + * locally detected moved head and a provider 409 read identically on mobile. + */ +export const BITBUCKET_STALE_HEAD_REASON = + 'The pull request changed since it was loaded. Reload the pull request and try again.'; + +/** The PR a write acts on. */ +export type BitbucketPrTarget = { + owner: BitbucketReviewOwner; + workspace: string; + repoSlug: string; + prId: number; +}; + +/** Every mutation accepts the router's ledger key and reports its outcome. */ +export type BitbucketMutationInput = { operationKey?: string }; + +export type BitbucketMutationResult = { + done: boolean; + /** True when the provider already held the target state — nothing changed. */ + replayed: boolean; +}; + +const BitbucketPullRequestWriteSchema = z.object({ + id: z.number(), + state: z.enum(['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED']), + source: z + .object({ + commit: z + .object({ hash: z.string().min(1) }) + .nullable() + .optional(), + }) + .nullable() + .optional(), +}); + +/** + * The comment fetch is an existence check only: Bitbucket comment payloads + * carry no task count, so thread resolution never reads one. + */ +const BitbucketCommentWriteSchema = z.object({ + id: z.number(), +}); + +const BitbucketTaskWriteSchema = z.object({ + id: z.number(), + resolved_on: z.string().nullable().optional(), + comment: z.object({ id: z.number() }).nullable().optional(), +}); + +/** The task collection walk when resolving a thread: bound the page follow. */ +const MAX_TASK_COLLECTION_PAGES = 10; + +function prPath(access: BitbucketRepositoryAccess, prId: number): string { + return `/2.0/repositories/${encodeURIComponent(access.workspace.slug)}/${encodeURIComponent(access.repository.slug)}/pullrequests/${prId}`; +} + +async function targetAccess(target: BitbucketPrTarget): Promise { + return authorizeRepository(target.owner, target.workspace, target.repoSlug); +} + +/** + * The account id Bitbucket attributes to this token's actions: the connected + * workspace's uuid, resolved server-side by the authorization layer. A + * workspace access token cannot resolve an account itself (`/2.0/user` + * answers 403 — the same limit the inbox documents), so the server-derived + * workspace uuid is the only participant id this layer can use. + */ +function ownAccountId(access: BitbucketRepositoryAccess): string { + return access.workspace.uuid; +} + +/** + * The Bitbucket `inline` block for one anchor: RIGHT anchors the destination + * line (`to`), LEFT the source line (`from`). `from`/`to` are source-side and + * destination-side line numbers, not a one-sided range — sending + * `from: startLine` for a RIGHT range would anchor an unrelated source line, + * and a LEFT range would invent a new-side line. So a `startLine` range + * anchors its end line on the tapped side; the range stays in the pending + * list and the ledger key, not in the provider position. + */ +function buildInlinePosition(anchor: ProviderReviewInlineAnchor): Record { + return anchor.side === 'RIGHT' + ? { path: anchor.path, to: anchor.line } + : { path: anchor.path, from: anchor.line }; +} + +/** + * Post a comment on the pull request. With an `anchor` this creates a real + * inline comment on the diff position; without one it posts a top-level + * comment, byte-identical to the previous behavior. + */ +export async function addComment( + target: BitbucketPrTarget & { + body: string; + anchor?: ProviderReviewInlineAnchor; + } & BitbucketMutationInput +): Promise { + const access = await targetAccess(target); + try { + await requestBitbucketJson(access, `${prPath(access, target.prId)}/comments`, { + method: 'POST', + body: { + content: { raw: target.body }, + ...(target.anchor ? { inline: buildInlinePosition(target.anchor) } : {}), + }, + }); + return { done: true, replayed: false }; + } catch (error) { + throw classifyBitbucketError(error); + } +} + +/** Reply inside an existing comment thread. */ +export async function replyToComment( + target: BitbucketPrTarget & { + commentId: string; + body: string; + } & BitbucketMutationInput +): Promise { + const parentId = Number(target.commentId); + if (!Number.isInteger(parentId) || parentId <= 0) { + throw new BitbucketReviewError('bad_request', 'The comment to reply to could not be found.'); + } + const access = await targetAccess(target); + try { + await requestBitbucketJson(access, `${prPath(access, target.prId)}/comments`, { + method: 'POST', + body: { content: { raw: target.body }, parent: { id: parentId } }, + }); + return { done: true, replayed: false }; + } catch (error) { + throw classifyBitbucketError(error); + } +} + +/** + * The partial-apply reason: an inline comment committed before a later + * rejection, so the provider already holds effects a replayed batch would + * duplicate. The retryable kind keeps the router's ledger row + * reconcile_pending instead of settling it failed. + */ +const BITBUCKET_INLINE_PARTIAL_APPLY_REASON = + 'Bitbucket applied part of this review before the request failed. Check the pull request before retrying.'; + +/** + * Submit a review. `approve` → PUT participants/{account_id} with + * `state: 'approved'`; `request_changes` → `state: 'changes_requested'`; + * `comment` → clear the caller's own approval state. An optional body is + * posted as a comment alongside the review state. An optional `comments` + * batch posts real inline comments on the diff BEFORE the review state and + * the summary comment, so a review carries GitHub-parity inline threads; + * once any inline comment has committed, every failure reports the + * retryable kind, so the router marks the ledger row reconcile_pending and + * a same-key retry never re-posts the committed comments as duplicates. + */ +export async function submitReview( + target: BitbucketPrTarget & { + event: 'approve' | 'request_changes' | 'comment'; + body?: string; + comments?: ProviderReviewInlineComment[]; + } & BitbucketMutationInput +): Promise { + if (target.event === 'comment' && !target.body && !target.comments?.length) { + throw new BitbucketReviewError('bad_request', 'A comment review needs a body.'); + } + const access = await targetAccess(target); + let inlineCommitted = false; + try { + for (const comment of target.comments ?? []) { + try { + await requestBitbucketJson(access, `${prPath(access, target.prId)}/comments`, { + method: 'POST', + body: { + content: { raw: comment.body }, + inline: buildInlinePosition(comment), + }, + }); + } catch (error) { + // A rejection after an earlier comment committed is a partial + // apply: the deterministic kind would settle the ledger row failed + // and let a key-rotating retry re-post the committed comments. + throw inlineCommitted + ? new BitbucketReviewError('retryable', BITBUCKET_INLINE_PARTIAL_APPLY_REASON) + : error; + } + inlineCommitted = true; + } + const accountId = await ownAccountId(access); + const state = + target.event === 'approve' + ? 'approved' + : target.event === 'request_changes' + ? 'changes_requested' + : null; + await requestBitbucketJson( + access, + `${prPath(access, target.prId)}/participants/${encodeURIComponent(accountId)}`, + { method: 'PUT', body: { state } } + ); + if (target.body) { + await requestBitbucketJson(access, `${prPath(access, target.prId)}/comments`, { + method: 'POST', + body: { content: { raw: target.body } }, + }); + } + return { done: true, replayed: false }; + } catch (error) { + const classified = classifyBitbucketError(error); + // Once an inline comment is live, every later failure — mid-batch or + // the state/summary step — is a partial apply: reconcile, never settle + // failed (see BITBUCKET_INLINE_PARTIAL_APPLY_REASON). + if (inlineCommitted && !classified.retryable) { + throw new BitbucketReviewError('retryable', BITBUCKET_INLINE_PARTIAL_APPLY_REASON); + } + throw classified; + } +} + +/** + * Walk the PR's task collection for the comment's first task matching + * `predicate`. The collection is paginated with opaque `next` URLs: follow + * every page (the same guarded, identity-bound page fetch the read layer + * uses) and remember whether any task belongs to the comment, because the + * decision needs all three outcomes: matching task found → mutate it; tasks + * seen but none matching → the target state already holds; no task for the + * comment at all → the capability reason. + */ +async function findCommentTask( + access: BitbucketRepositoryAccess, + prId: number, + commentId: number, + predicate: (task: z.infer) => boolean +): Promise<{ + task: z.infer | null; + sawTaskForComment: boolean; + exhausted: boolean; +}> { + let task: z.infer | null = null; + let sawTaskForComment = false; + let cursor: string | undefined = undefined; + let exhausted = true; + try { + for (let pageIndex = 0; pageIndex < MAX_TASK_COLLECTION_PAGES; pageIndex++) { + const page = await fetchPage( + access, + `${prPath(access, prId)}/tasks`, + `bitbucket-tasks:${access.repository.fullName}#${prId}`, + cursor, + repositoryPathGuard(access), + { pagelen: 100 } + ); + for (const value of page.values) { + const parsed = BitbucketTaskWriteSchema.safeParse(value); + if (!parsed.success) continue; + if (parsed.data.comment?.id !== commentId) continue; + sawTaskForComment = true; + if (predicate(parsed.data)) { + task = parsed.data; + break; + } + } + if (task) break; + if (!page.nextCursor) { + exhausted = true; + break; + } + exhausted = false; + cursor = page.nextCursor; + } + } catch (error) { + if (error instanceof BitbucketReviewError && error.kind === 'not_found') { + throw new BitbucketReviewError('bad_request', BITBUCKET_THREAD_RESOLUTION_UNSUPPORTED_REASON); + } + throw error; + } + return { task, sawTaskForComment, exhausted }; +} + +/** + * Resolve a thread by resolving the root comment's task. Bitbucket comments + * carry no task count, so the decision comes from the task-collection walk + * alone: an unresolved task on the comment is resolved, a fully resolved set + * of tasks reports the replay, and a thread without any task is refused with + * the explicit capability reason — never a silent fallback. + */ +export async function resolveThread( + target: BitbucketPrTarget & { threadId: string } & BitbucketMutationInput +): Promise { + const commentId = Number(target.threadId); + if (!Number.isInteger(commentId) || commentId <= 0) { + throw new BitbucketReviewError('not_found', 'This discussion thread could not be found.'); + } + const access = await targetAccess(target); + try { + // The comment fetch is an existence check only: a missing comment 404s + // into a non-retryable not_found before the task walk runs. + BitbucketCommentWriteSchema.parse( + await requestBitbucketJson( + access, + `${prPath(access, target.prId)}/comments/${commentId}` + ) + ); + + const { task, sawTaskForComment, exhausted } = await findCommentTask( + access, + target.prId, + commentId, + candidate => candidate.resolved_on == null + ); + if (!task) { + if (!exhausted) { + // The collection paginated past the walk bound without ever showing + // the comment's unresolved task: report a retryable failure instead + // of claiming an unverified state. + throw new BitbucketReviewError( + 'retryable', + 'The Bitbucket task list is too large to resolve this thread. Try again.' + ); + } + if (sawTaskForComment) { + // Every one of the comment's tasks is already resolved: the target + // state already holds. + return { done: true, replayed: true }; + } + // No task exists for this comment, so the provider exposes no + // resolution affordance at all. + throw new BitbucketReviewError('bad_request', BITBUCKET_THREAD_RESOLUTION_UNSUPPORTED_REASON); + } + await requestBitbucketJson(access, `${prPath(access, target.prId)}/tasks/${task.id}`, { + method: 'PUT', + body: { resolved: true }, + }); + return { done: true, replayed: false }; + } catch (error) { + throw classifyBitbucketError(error); + } +} + +/** + * Un-resolve a thread by reopening the root comment's resolved task — the + * mirror of resolveThread: a resolved task on the comment is reopened, a + * fully unresolved set reports the replay, and a thread without any task is + * refused with the explicit capability reason. + */ +export async function unresolveThread( + target: BitbucketPrTarget & { threadId: string } & BitbucketMutationInput +): Promise { + const commentId = Number(target.threadId); + if (!Number.isInteger(commentId) || commentId <= 0) { + throw new BitbucketReviewError('not_found', 'This discussion thread could not be found.'); + } + const access = await targetAccess(target); + try { + BitbucketCommentWriteSchema.parse( + await requestBitbucketJson( + access, + `${prPath(access, target.prId)}/comments/${commentId}` + ) + ); + + const { task, sawTaskForComment, exhausted } = await findCommentTask( + access, + target.prId, + commentId, + candidate => candidate.resolved_on != null + ); + if (!task) { + if (!exhausted) { + throw new BitbucketReviewError( + 'retryable', + 'The Bitbucket task list is too large to reopen this thread. Try again.' + ); + } + if (sawTaskForComment) { + // No task of the comment is resolved: the target state already holds. + return { done: true, replayed: true }; + } + throw new BitbucketReviewError('bad_request', BITBUCKET_THREAD_RESOLUTION_UNSUPPORTED_REASON); + } + await requestBitbucketJson(access, `${prPath(access, target.prId)}/tasks/${task.id}`, { + method: 'PUT', + body: { resolved: false }, + }); + return { done: true, replayed: false }; + } catch (error) { + throw classifyBitbucketError(error); + } +} + +/** + * Re-fetch the PR and compare the current head against the caller's fence. + * A moved head is refused BEFORE any merge call, so a stale revision can + * never merge another commit or be redirected. + */ +function requireHeadShaFence( + pr: z.infer, + expectedHeadSha: string +): void { + const currentHead = pr.source?.commit?.hash ?? ''; + if (currentHead !== expectedHeadSha) { + throw new BitbucketReviewError('stale_head', BITBUCKET_STALE_HEAD_REASON); + } +} + +/** + * Merge the pull request. The caller's `expectedHeadSha` is re-verified + * against a fresh fetch BEFORE any merge call, so the merge can only land the + * exact revision the reviewer saw. `closeSourceBranch` is honored. + */ +export async function mergePullRequest( + target: BitbucketPrTarget & { + expectedHeadSha: string; + closeSourceBranch?: boolean; + commitMessage?: string; + } & BitbucketMutationInput +): Promise { + const access = await targetAccess(target); + try { + const pr = BitbucketPullRequestWriteSchema.parse( + await requestBitbucketJson(access, prPath(access, target.prId)) + ); + if (pr.state === 'MERGED') { + // The target state already holds: report the replay, run no effect. + return { done: true, replayed: true }; + } + requireHeadShaFence(pr, target.expectedHeadSha); + if (pr.state !== 'OPEN') { + throw new BitbucketReviewError('bad_request', 'The pull request is closed.'); + } + await requestBitbucketJson(access, `${prPath(access, target.prId)}/merge`, { + method: 'POST', + body: { + close_source_branch: target.closeSourceBranch ?? false, + // Bitbucket's merge endpoint names the commit message `message` + // (GitHub's `commit_message` field name is not read by Bitbucket). + ...(target.commitMessage ? { message: target.commitMessage } : {}), + }, + }); + return { done: true, replayed: false }; + } catch (error) { + throw classifyBitbucketError(error); + } +} diff --git a/apps/web/src/lib/provider-review/gitlab-authorization.test.ts b/apps/web/src/lib/provider-review/gitlab-authorization.test.ts new file mode 100644 index 0000000000..aecc7984e8 --- /dev/null +++ b/apps/web/src/lib/provider-review/gitlab-authorization.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it, beforeEach } from '@jest/globals'; +import { TRPCError } from '@trpc/server'; +import type { PlatformIntegration } from '@kilocode/db/schema'; +import type { Owner } from '@/lib/integrations/core/types'; +import { + authorizeOwner, + authorizeProject, + classifyGitLabError, + classifyGitLabStatus, + GitLabApiStatusError, + GitLabReviewError, + type GitLabReviewOwner, +} from './gitlab-authorization'; + +const mockGetIntegrationForOwner = jest.fn(); +const mockGetValidGitLabToken = jest.fn(); + +/** Await a rejection and return it typed, without a success-branch union. */ +async function captureRejection(promise: Promise): Promise { + try { + await promise; + } catch (reason) { + return reason as GitLabReviewError; + } + throw new Error('Expected the call to reject.'); +} + +jest.mock('@/lib/integrations/db/platform-integrations', () => ({ + getIntegrationForOwner: (owner: Owner, platform: string) => + mockGetIntegrationForOwner(owner, platform), +})); + +jest.mock('@/lib/integrations/gitlab-service', () => ({ + getValidGitLabToken: ( + integration: PlatformIntegration, + actor: { userId: string; organizationId?: string } + ) => mockGetValidGitLabToken(integration, actor), +})); + +const SELF_MANAGED_USER: GitLabReviewOwner = { type: 'user', userId: 'user_1' }; +const SELF_MANAGED_ORG: GitLabReviewOwner = { + type: 'organization', + organizationId: 'org_1', + userId: 'user_1', +}; + +function integrationRow(overrides: { + gitlab_instance_url?: string; + repositories?: { id: number; name: string; full_name: string; private: boolean }[]; + status?: string; +}): PlatformIntegration { + return { + id: 'intg_1', + platform: 'gitlab', + integration_status: overrides.status ?? 'active', + owned_by_user_id: 'user_1', + owned_by_organization_id: null, + metadata: + overrides.gitlab_instance_url === undefined + ? {} + : { gitlab_instance_url: overrides.gitlab_instance_url }, + repositories: overrides.repositories ?? [ + { id: 7, name: 'repo', full_name: 'group/sub/repo', private: true }, + { id: 8, name: 'other', full_name: 'group/other', private: false }, + ], + } as unknown as PlatformIntegration; +} + +function mockActiveIntegration(integration: PlatformIntegration): void { + mockGetIntegrationForOwner.mockResolvedValue(integration); + mockGetValidGitLabToken.mockResolvedValue('glpat-mock-token'); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('authorizeProject — owner resolution', () => { + it('resolves a user owner through getIntegrationForOwner with the user id', async () => { + mockActiveIntegration(integrationRow({ gitlab_instance_url: 'https://gitlab.example.com' })); + + const access = await authorizeProject(SELF_MANAGED_USER, 'group/sub/repo'); + + expect(mockGetIntegrationForOwner).toHaveBeenCalledWith( + { type: 'user', id: 'user_1' }, + 'gitlab' + ); + expect(mockGetValidGitLabToken).toHaveBeenCalledWith(expect.anything(), { userId: 'user_1' }); + expect(access.accessToken).toBe('glpat-mock-token'); + expect(access.instanceUrl).toBe('https://gitlab.example.com'); + expect(access.projectPath).toBe('group/sub/repo'); + }); + + it('resolves an organization owner and passes the acting user to the token broker', async () => { + mockActiveIntegration(integrationRow({ gitlab_instance_url: 'https://gitlab.example.com' })); + + await authorizeProject(SELF_MANAGED_ORG, 'group/sub/repo'); + + expect(mockGetIntegrationForOwner).toHaveBeenCalledWith({ type: 'org', id: 'org_1' }, 'gitlab'); + expect(mockGetValidGitLabToken).toHaveBeenCalledWith(expect.anything(), { + userId: 'user_1', + organizationId: 'org_1', + }); + }); + + it('refuses when no integration exists', async () => { + mockGetIntegrationForOwner.mockResolvedValue(null); + + await expect(authorizeProject(SELF_MANAGED_USER, 'group/sub/repo')).rejects.toMatchObject({ + name: 'GitLabReviewError', + kind: 'not_found', + retryable: false, + }); + expect(mockGetValidGitLabToken).not.toHaveBeenCalled(); + }); + + it('refuses a non-active integration', async () => { + mockGetIntegrationForOwner.mockResolvedValue( + integrationRow({ gitlab_instance_url: 'https://gitlab.example.com', status: 'suspended' }) + ); + + await expect(authorizeProject(SELF_MANAGED_USER, 'group/sub/repo')).rejects.toBeInstanceOf( + GitLabReviewError + ); + }); +}); + +describe('authorizeProject — instance derivation', () => { + it('derives the instance URL from metadata only', async () => { + mockActiveIntegration(integrationRow({ gitlab_instance_url: 'https://gitlab.acme.dev/' })); + + const access = await authorizeProject(SELF_MANAGED_USER, 'group/sub/repo'); + + expect(access.instanceUrl).toBe('https://gitlab.acme.dev'); + }); + + it('defaults to gitlab.com when metadata has no instance URL', async () => { + mockActiveIntegration(integrationRow({})); + + const access = await authorizeProject(SELF_MANAGED_USER, 'group/sub/repo'); + + expect(access.instanceUrl).toBe('https://gitlab.com'); + }); + + it('refuses a mismatched instance hint as not_found without resolving a token', async () => { + mockActiveIntegration(integrationRow({})); + + const error = await captureRejection( + authorizeProject(SELF_MANAGED_USER, 'group/sub/repo', 'https://gitlab.acme.dev') + ); + + expect(error).toBeInstanceOf(GitLabReviewError); + expect(error.kind).toBe('not_found'); + expect(error.retryable).toBe(false); + // The refusal never re-targets: no token is fetched, and the message + // leaks neither the hint nor the connected host. + expect(mockGetValidGitLabToken).not.toHaveBeenCalled(); + expect(error.message).not.toContain('acme.dev'); + expect(error.message).not.toContain('gitlab.com'); + }); + + it('accepts a hint whose origin matches the connected instance', async () => { + mockActiveIntegration(integrationRow({ gitlab_instance_url: 'https://gitlab.example.com' })); + + const access = await authorizeProject( + SELF_MANAGED_USER, + 'group/sub/repo', + 'https://GitLab.example.com/some/other/path' + ); + + expect(access.instanceUrl).toBe('https://gitlab.example.com'); + }); +}); + +describe('authorizeProject — repository matching', () => { + it('matches a nested project path case-insensitively end-to-end', async () => { + mockActiveIntegration(integrationRow({})); + + const access = await authorizeProject(SELF_MANAGED_USER, 'GROUP/Sub/REPO'); + + expect(access.projectPath).toBe('group/sub/repo'); + }); + + it('refuses a prefix or suffix of a nested path (exact full path only)', async () => { + mockActiveIntegration(integrationRow({})); + + await expect(authorizeProject(SELF_MANAGED_USER, 'group/sub')).rejects.toMatchObject({ + kind: 'not_found', + }); + await expect(authorizeProject(SELF_MANAGED_USER, 'sub/repo')).rejects.toMatchObject({ + kind: 'not_found', + }); + }); + + it('refuses a project that is not among the integration repositories', async () => { + mockActiveIntegration(integrationRow({})); + + await expect(authorizeProject(SELF_MANAGED_USER, 'other/team/repo')).rejects.toMatchObject({ + kind: 'not_found', + }); + expect(mockGetValidGitLabToken).not.toHaveBeenCalled(); + }); + + it('refuses when the cached repository list is empty', async () => { + mockActiveIntegration(integrationRow({ repositories: [] })); + + await expect(authorizeProject(SELF_MANAGED_USER, 'group/sub/repo')).rejects.toMatchObject({ + kind: 'not_found', + }); + }); +}); + +describe('authorizeOwner', () => { + it('returns token and server-derived instance without a project check', async () => { + mockActiveIntegration(integrationRow({ gitlab_instance_url: 'https://gitlab.example.com' })); + + const access = await authorizeOwner(SELF_MANAGED_USER); + + expect(access.accessToken).toBe('glpat-mock-token'); + expect(access.instanceUrl).toBe('https://gitlab.example.com'); + }); + + it('applies the same instance-hint guard', async () => { + mockActiveIntegration(integrationRow({ gitlab_instance_url: 'https://gitlab.example.com' })); + + await expect(authorizeOwner(SELF_MANAGED_USER, 'https://gitlab.com')).rejects.toMatchObject({ + kind: 'not_found', + }); + }); +}); + +describe('credential failures are classified', () => { + it('maps an expired connection to non-retryable forbidden', async () => { + mockGetIntegrationForOwner.mockResolvedValue(integrationRow({})); + mockGetValidGitLabToken.mockRejectedValue( + new TRPCError({ code: 'UNAUTHORIZED', message: 'reconnect' }) + ); + + await expect(authorizeProject(SELF_MANAGED_USER, 'group/sub/repo')).rejects.toMatchObject({ + kind: 'forbidden', + retryable: false, + }); + }); + + it('maps a temporarily unavailable broker to retryable', async () => { + mockGetIntegrationForOwner.mockResolvedValue(integrationRow({})); + mockGetValidGitLabToken.mockRejectedValue( + new TRPCError({ code: 'SERVICE_UNAVAILABLE', message: 'later' }) + ); + + await expect(authorizeProject(SELF_MANAGED_USER, 'group/sub/repo')).rejects.toMatchObject({ + kind: 'retryable', + retryable: true, + }); + }); +}); + +describe('classifyGitLabStatus / classifyGitLabError', () => { + it('classifies 404, 403, and 409 as non-retryable kinds', () => { + expect(classifyGitLabStatus(404).kind).toBe('not_found'); + expect(classifyGitLabStatus(403).kind).toBe('forbidden'); + expect(classifyGitLabStatus(409).kind).toBe('stale_head'); + expect(classifyGitLabStatus(409).retryable).toBe(false); + }); + + it('classifies 5xx and 429 as retryable', () => { + expect(classifyGitLabStatus(502).retryable).toBe(true); + expect(classifyGitLabStatus(429).kind).toBe('retryable'); + }); + + it('extracts the status from adapter error message tails', () => { + const classified = classifyGitLabError(new Error('GitLab MR fetch failed: 404')); + expect(classified.kind).toBe('not_found'); + }); + + it('never echoes provider bodies into the classified message', () => { + const leaked = new GitLabApiStatusError( + 403, + 'GitLab PUT request failed: 403 {"message":"token glpat-secret for https://gitlab.acme.dev denied"}' + ); + const classified = classifyGitLabError(leaked); + expect(classified.kind).toBe('forbidden'); + expect(classified.message).not.toContain('glpat-secret'); + expect(classified.message).not.toContain('acme.dev'); + }); + + it('classifies network failures as retryable', () => { + expect(classifyGitLabError(new TypeError('fetch failed')).retryable).toBe(true); + }); +}); diff --git a/apps/web/src/lib/provider-review/gitlab-authorization.ts b/apps/web/src/lib/provider-review/gitlab-authorization.ts new file mode 100644 index 0000000000..451510f537 --- /dev/null +++ b/apps/web/src/lib/provider-review/gitlab-authorization.ts @@ -0,0 +1,283 @@ +/** + * Server-derived GitLab credentials for the MR review layer. + * + * A caller supplies an owner, a project path, and an optional display-only + * instance hint. The instance URL and the token are resolved here and only + * here: the hint is compared against the connected instance and never used + * to build a request. A pasted self-managed URL therefore can never + * re-target a connected token at another host. + */ +import 'server-only'; + +import { TRPCError } from '@trpc/server'; +import type { PlatformIntegration } from '@kilocode/db/schema'; +import { gitlabInstanceOrigin } from '@kilocode/app-shared/provider-review'; +import { INTEGRATION_STATUS, PLATFORM } from '@/lib/integrations/core/constants'; +import type { Owner } from '@/lib/integrations/core/types'; +import { requireNumericPlatformRepositories } from '@/lib/integrations/core/types'; +import { getIntegrationForOwner } from '@/lib/integrations/db/platform-integrations'; +import { getValidGitLabToken } from '@/lib/integrations/gitlab-service'; +import { + DEFAULT_GITLAB_INSTANCE_URL, + GitLabInstanceUrlError, + normalizeGitLabInstanceUrl, +} from '@/lib/integrations/platforms/gitlab/instance-url'; +import { logExceptInTest } from '@/lib/utils.server'; + +/** + * The account that owns the GitLab integration. `userId` is the acting user + * whose credential the token broker releases (the owner id for a user). + */ +export type GitLabReviewOwner = + | { type: 'user'; userId: string } + | { type: 'organization'; organizationId: string; userId: string }; + +export type GitLabReviewErrorKind = + | 'not_found' + | 'forbidden' + | 'stale_head' + | 'bad_request' + | 'retryable'; + +/** + * A classified provider failure. `retryable` is true only for 5xx/network + * outcomes. The message is fixed copy and never embeds a token or an + * instance URL, so every output of this layer is safe to show or log. + */ +export class GitLabReviewError extends Error { + readonly kind: GitLabReviewErrorKind; + readonly retryable: boolean; + + constructor(kind: GitLabReviewErrorKind, message: string) { + super(message); + this.name = 'GitLabReviewError'; + this.kind = kind; + this.retryable = kind === 'retryable'; + } +} + +/** An HTTP failure raised by this layer's own GitLab JSON requests. */ +export class GitLabApiStatusError extends Error { + constructor( + readonly status: number, + message: string + ) { + super(message); + this.name = 'GitLabApiStatusError'; + } +} + +/** GitLab status → kind: 404 not_found, 401/403 forbidden, 409 stale head, 5xx/429 retryable. */ +export function classifyGitLabStatus(status: number): GitLabReviewError { + if (status === 404) { + return new GitLabReviewError( + 'not_found', + 'The GitLab merge request or project was not found, or you do not have access to it.' + ); + } + if (status === 401 || status === 403) { + return new GitLabReviewError( + 'forbidden', + 'Your GitLab role does not allow this action on this merge request.' + ); + } + if (status === 409) { + return new GitLabReviewError( + 'stale_head', + 'The merge request changed since it was loaded. Reload the merge request and try again.' + ); + } + if (status === 400 || status === 405 || status === 422) { + return new GitLabReviewError('bad_request', 'GitLab rejected this request.'); + } + if (status === 429 || status >= 500) { + return new GitLabReviewError('retryable', 'GitLab is temporarily unavailable. Try again.'); + } + return new GitLabReviewError('retryable', 'GitLab returned an unexpected error.'); +} + +function isNetworkFailure(error: Error): boolean { + return ( + error.name === 'TypeError' || + error.name === 'TimeoutError' || + error.name === 'AbortError' || + error.message.toLowerCase().includes('fetch failed') + ); +} + +function classifyTrpcError(code: TRPCError['code']): GitLabReviewError { + switch (code) { + case 'NOT_FOUND': + return new GitLabReviewError('not_found', 'GitLab integration not found.'); + case 'UNAUTHORIZED': + return new GitLabReviewError('forbidden', 'Your GitLab connection is no longer valid.'); + case 'SERVICE_UNAVAILABLE': + return new GitLabReviewError('retryable', 'GitLab credentials are temporarily unavailable.'); + default: + return new GitLabReviewError('retryable', 'Could not resolve your GitLab credentials.'); + } +} + +/** + * Map one provider failure onto the mobile error states. + * + * The adapter helpers throw plain Errors whose message ends with the status + * code (e.g. `GitLab MR fetch failed: 403`); the status number is the only + * provider detail that survives here, so no response body — and no token — + * can leak into the classified message. + */ +export function classifyGitLabError(error: unknown): GitLabReviewError { + if (error instanceof GitLabReviewError) return error; + if (error instanceof GitLabInstanceUrlError) { + return new GitLabReviewError('bad_request', 'The GitLab instance URL is not allowed.'); + } + if (error instanceof TRPCError) { + return classifyTrpcError(error.code); + } + if (error instanceof GitLabApiStatusError) { + return classifyGitLabStatus(error.status); + } + if (error instanceof Error) { + const status = error.message.match(/:\s*(\d{3})\b/); + if (status?.[1]) { + return classifyGitLabStatus(Number(status[1])); + } + if (isNetworkFailure(error)) { + return new GitLabReviewError('retryable', 'Could not reach GitLab. Please try again.'); + } + } + logExceptInTest('[gitlab-authorization] Unclassified GitLab failure:', error); + return new GitLabReviewError('retryable', 'GitLab returned an unexpected error.'); +} + +/** The credentials and canonical project path one request may use. */ +export type GitLabProjectAccess = { + accessToken: string; + /** Server-derived instance base URL — never a caller-supplied value. */ + instanceUrl: string; + /** The integration's repository full path, matched case-insensitively. */ + projectPath: string; + owner: GitLabReviewOwner; +}; + +function ownerToDbOwner(owner: GitLabReviewOwner): Owner { + return owner.type === 'user' + ? { type: 'user', id: owner.userId } + : { type: 'org', id: owner.organizationId }; +} + +function actorFor(owner: GitLabReviewOwner): { userId: string; organizationId?: string } { + return owner.type === 'user' + ? { userId: owner.userId } + : { userId: owner.userId, organizationId: owner.organizationId }; +} + +function readInstanceUrl(integration: PlatformIntegration): string { + const metadata = integration.metadata; + const raw = + typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata) + ? (metadata as { gitlab_instance_url?: unknown }).gitlab_instance_url + : undefined; + // Same rule as gitlab-integration-helpers.ts:128,182 — the stored metadata + // is the only source, and an absent URL means gitlab.com. + const instanceUrl = typeof raw === 'string' && raw ? raw : DEFAULT_GITLAB_INSTANCE_URL; + try { + return normalizeGitLabInstanceUrl(instanceUrl); + } catch (error) { + throw classifyGitLabError(error); + } +} + +/** + * The active integration row plus the server-derived instance URL, with the + * instance-hint origin guard applied. Throws a GitLabReviewError on every + * refusal; a hint whose origin differs is refused as not_found. + */ +async function resolveIntegration( + owner: GitLabReviewOwner, + instanceHint?: string +): Promise<{ integration: PlatformIntegration; instanceUrl: string }> { + const integration = await getIntegrationForOwner(ownerToDbOwner(owner), PLATFORM.GITLAB); + if (!integration) { + throw new GitLabReviewError( + 'not_found', + 'No GitLab connection found for this account. Connect GitLab first.' + ); + } + if (integration.integration_status !== INTEGRATION_STATUS.ACTIVE) { + throw new GitLabReviewError('not_found', 'The GitLab connection is no longer active.'); + } + + const instanceUrl = readInstanceUrl(integration); + if (instanceHint && gitlabInstanceOrigin(instanceHint) !== gitlabInstanceOrigin(instanceUrl)) { + // A pasted self-managed URL must never re-target the connected token to + // another host: refuse as not-found without revealing the instance. + throw new GitLabReviewError( + 'not_found', + 'This merge request is not available on your connected GitLab instance.' + ); + } + return { integration, instanceUrl }; +} + +/** + * Resolve the active integration for the owner and return a fresh token plus + * the server-derived instance URL. The inbox has no project to verify, so it + * uses this instead of authorizeProject. + */ +export async function authorizeOwner( + owner: GitLabReviewOwner, + instanceHint?: string +): Promise<{ accessToken: string; instanceUrl: string; owner: GitLabReviewOwner }> { + const { integration, instanceUrl } = await resolveIntegration(owner, instanceHint); + try { + const accessToken = await getValidGitLabToken(integration, actorFor(owner)); + return { accessToken, instanceUrl, owner }; + } catch (error) { + throw classifyGitLabError(error); + } +} + +function cleanProjectPath(projectPath: string): string { + return projectPath.trim().replace(/^\/+|\/+$/g, ''); +} + +/** + * Verify the project path is among the integration's repositories with the + * same case-insensitive exact full-path match as + * validateGitLabRepoAccessForUser/Organization + * (gitlab-integration-helpers.ts:219-259). Nested `group/sub/repo` must + * match end-to-end. Returns the server-derived token, instance URL, and the + * integration's canonical project path. + */ +export async function authorizeProject( + owner: GitLabReviewOwner, + projectPath: string, + instanceHint?: string +): Promise { + const requested = cleanProjectPath(projectPath); + const { integration, instanceUrl } = await resolveIntegration(owner, instanceHint); + + let repositories: ReturnType; + try { + repositories = requireNumericPlatformRepositories(integration.repositories); + } catch { + repositories = null; + } + const match = repositories?.find( + repo => repo.full_name.toLowerCase() === requested.toLowerCase() + ); + if (!match) { + throw new GitLabReviewError( + 'not_found', + 'This project is not part of your connected GitLab repositories.' + ); + } + + try { + const accessToken = await getValidGitLabToken(integration, actorFor(owner)); + return { accessToken, instanceUrl, projectPath: match.full_name, owner }; + } catch (error) { + throw classifyGitLabError(error); + } +} diff --git a/apps/web/src/lib/provider-review/gitlab-read.test.ts b/apps/web/src/lib/provider-review/gitlab-read.test.ts new file mode 100644 index 0000000000..efee544046 --- /dev/null +++ b/apps/web/src/lib/provider-review/gitlab-read.test.ts @@ -0,0 +1,893 @@ +import { describe, expect, it, beforeEach, afterEach } from '@jest/globals'; +import { EventEmitter } from 'events'; +import * as https from 'https'; +import { PassThrough } from 'stream'; +import type { PlatformIntegration } from '@kilocode/db/schema'; +import type { Owner } from '@/lib/integrations/core/types'; +import { GitLabInstanceUrlError } from '@/lib/integrations/platforms/gitlab/instance-url'; +import { + getFileLines, + getMergeRequest, + getMergeState, + listChangedFiles, + listChecks, + listDiscussions, + listInbox, +} from './gitlab-read'; +import { GitLabReviewError } from './gitlab-authorization'; + +// The bound transport runs through Node https.request; mirror adapter.test.ts. +jest.mock('https', () => ({ + request: jest.fn(), +})); + +const mockGetIntegrationForOwner = jest.fn(); +const mockGetValidGitLabToken = jest.fn(); +const mockFetchGitLabMergeRequest = jest.fn(); +const mockGetMRHeadCommit = jest.fn(); +const mockGetMRDiffRefs = jest.fn(); +const mockFetchGitLabRootTextFileAtRef = jest.fn(); +const mockFetchGitLabUser = jest.fn(); +const mockResolveGitLabUrlSafely = jest.fn(); + +jest.mock('@/lib/integrations/db/platform-integrations', () => ({ + getIntegrationForOwner: (owner: Owner, platform: string) => + mockGetIntegrationForOwner(owner, platform), +})); + +jest.mock('@/lib/integrations/gitlab-service', () => ({ + getValidGitLabToken: (integration: PlatformIntegration, actor: unknown) => + mockGetValidGitLabToken(integration, actor), +})); + +jest.mock('@/lib/integrations/platforms/gitlab/adapter', () => ({ + fetchGitLabMergeRequest: (params: unknown) => mockFetchGitLabMergeRequest(params), + getMRHeadCommit: (...args: unknown[]) => mockGetMRHeadCommit(...args), + getMRDiffRefs: (...args: unknown[]) => mockGetMRDiffRefs(...args), + fetchGitLabRootTextFileAtRef: (...args: unknown[]) => mockFetchGitLabRootTextFileAtRef(...args), + fetchGitLabUser: (...args: unknown[]) => mockFetchGitLabUser(...args), +})); + +// Keep the real URL builder; stub the resolved-URL guard so unit tests need no +// network. Default (set in beforeEach): no pinned address, so requests keep the +// plain fetch transport the assertions read. Transport tests rebind it per test. +jest.mock('@/lib/integrations/platforms/gitlab/instance-url', () => { + const actual = jest.requireActual('@/lib/integrations/platforms/gitlab/instance-url'); + return { + ...actual, + resolveGitLabUrlSafely: (urlString: string) => mockResolveGitLabUrlSafely(urlString), + }; +}); + +const mockHttpsRequest = https.request as unknown as jest.Mock; + +const OWNER: { type: 'user'; userId: string } = { type: 'user', userId: 'user_1' }; +const INSTANCE_URL = 'https://gitlab.example.com'; +const PROJECT_PATH = 'group/sub/repo'; + +/** Await a rejection and return it typed, without a success-branch union. */ +async function captureRejection(promise: Promise): Promise { + try { + await promise; + } catch (reason) { + return reason as GitLabReviewError; + } + throw new Error('Expected the call to reject.'); +} + +const integrationRow = { + id: 'intg_1', + platform: 'gitlab', + integration_status: 'active', + owned_by_user_id: 'user_1', + owned_by_organization_id: null, + metadata: { gitlab_instance_url: INSTANCE_URL }, + repositories: [{ id: 7, name: 'repo', full_name: PROJECT_PATH, private: true }], +} as unknown as PlatformIntegration; + +const mrFixture = { + id: 100, + iid: 12, + title: 'Add nested deploy script', + description: 'Body here', + state: 'opened', + draft: false, + source_branch: 'feature/deploy', + target_branch: 'main', + sha: 'sha-head', + diff_refs: { base_sha: 'sha-base', head_sha: 'sha-head', start_sha: 'sha-start' }, + web_url: `${INSTANCE_URL}/group/sub/repo/-/merge_requests/12`, + author: { id: 1, username: 'alice', name: 'Alice', avatar_url: null }, + created_at: '2026-01-02T00:00:00Z', + updated_at: '2026-01-03T00:00:00Z', + has_conflicts: false, + merge_status: 'can_be_merged', + head_pipeline: { + id: 1, + sha: 'sha-head', + ref: 'feature/deploy', + status: 'success', + web_url: `${INSTANCE_URL}/-/pipelines/1`, + }, + references: { full: 'group/sub/repo!12' }, +}; + +const diffFixture = [ + { + old_path: 'scripts/deploy.sh', + new_path: 'scripts/deploy.sh', + new_file: true, + renamed_file: false, + deleted_file: false, + diff: '@@ -0,0 +1,2 @@\n+set -e\n+echo done\n', + }, + { + old_path: 'README.md', + new_path: 'README.md', + new_file: false, + renamed_file: false, + deleted_file: false, + diff: '@@ -1,2 +1,2 @@\n-old\n+new\n', + }, +]; + +let fetchMock: jest.Mock; + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function lastFetchUrl(): URL { + const last = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; + return new URL(String(last[0])); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockResolveGitLabUrlSafely.mockImplementation(async (urlString: string) => ({ + url: new URL(urlString), + })); + mockGetIntegrationForOwner.mockResolvedValue(integrationRow); + mockGetValidGitLabToken.mockResolvedValue('glpat-mock-token'); + mockFetchGitLabMergeRequest.mockResolvedValue(mrFixture); + mockGetMRHeadCommit.mockResolvedValue('sha-head'); + mockGetMRDiffRefs.mockResolvedValue({ + baseSha: 'sha-base', + headSha: 'sha-head', + startSha: 'sha-start', + }); + fetchMock = jest.fn(); + fetchMock.mockImplementation((url: string) => { + const path = new URL(url).pathname; + if (path.endsWith('/diffs')) return Promise.resolve(jsonResponse(diffFixture)); + if (path.endsWith('/discussions')) return Promise.resolve(jsonResponse([])); + if (path.endsWith('/pipelines')) return Promise.resolve(jsonResponse([])); + if (path.endsWith('/approvals')) + return Promise.resolve(jsonResponse({ approvals_required: 0, approvals_left: 0 })); + return Promise.resolve(jsonResponse([])); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('getMergeRequest', () => { + it('maps detail, head sha, diff refs, and diff counts into the s1 summary', async () => { + const summary = await getMergeRequest(OWNER, PROJECT_PATH, 12); + + expect(summary).toMatchObject({ + ref: { platform: 'gitlab', projectPath: PROJECT_PATH, mrIid: 12, instanceHint: INSTANCE_URL }, + title: 'Add nested deploy script', + body: 'Body here', + author: { login: 'alice', avatarUrl: null }, + state: 'open', + draft: false, + headRef: 'feature/deploy', + baseRef: 'main', + headSha: 'sha-head', + changedFiles: 2, + additions: 3, + deletions: 1, + createdAt: '2026-01-02T00:00:00Z', + updatedAt: '2026-01-03T00:00:00Z', + }); + // Adapter helpers were called with the SERVER-DERIVED instance URL. + expect(mockFetchGitLabMergeRequest).toHaveBeenCalledWith({ + accessToken: 'glpat-mock-token', + projectId: PROJECT_PATH, + mrIid: 12, + instanceUrl: INSTANCE_URL, + }); + }); + + it('marks a draft MR draft from the title when the flag is absent', async () => { + mockFetchGitLabMergeRequest.mockResolvedValue({ + ...mrFixture, + draft: undefined, + work_in_progress: undefined, + title: 'Draft: unfinished work', + }); + + const summary = await getMergeRequest(OWNER, PROJECT_PATH, 12); + + expect(summary.draft).toBe(true); + }); + + it('authorizes before any request: an unknown project never reaches GitLab', async () => { + await expect(getMergeRequest(OWNER, 'other/project', 12)).rejects.toMatchObject({ + kind: 'not_found', + }); + expect(mockFetchGitLabMergeRequest).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('tolerates an MR whose diff_refs are absent and falls back to the head sha', async () => { + // The adapter's getMRDiffRefs dereferences mr.diff_refs.base_sha and + // crashes an MR detail load when GitLab omits diff_refs; the summary + // mapping already tolerates absence, so the detail must be read from the + // MR response itself instead of through the crashing helper. + mockFetchGitLabMergeRequest.mockResolvedValue({ ...mrFixture, diff_refs: null }); + + const summary = await getMergeRequest(OWNER, PROJECT_PATH, 12); + + expect(summary.headSha).toBe('sha-head'); + expect(mockGetMRDiffRefs).not.toHaveBeenCalled(); + }); +}); + +describe('listChangedFiles', () => { + it('returns mapped files with per-file counts and a next cursor only for a full page', async () => { + const page = await listChangedFiles(OWNER, PROJECT_PATH, 12); + + expect(page.files[0]).toMatchObject({ + path: 'scripts/deploy.sh', + previousPath: null, + status: 'added', + additions: 2, + deletions: 0, + patchMissing: false, + }); + expect(page.files[1]).toMatchObject({ status: 'modified', additions: 1, deletions: 1 }); + expect(page.nextCursor).toBeNull(); + }); + + it('keeps page identity in the cursor and requests the next page', async () => { + const manyDiffs = Array.from({ length: 50 }, (_, index) => ({ + old_path: `f${index}.ts`, + new_path: `f${index}.ts`, + new_file: false, + renamed_file: false, + deleted_file: false, + diff: '+x\n', + })); + fetchMock.mockImplementation(() => Promise.resolve(jsonResponse(manyDiffs))); + + const first = await listChangedFiles(OWNER, PROJECT_PATH, 12); + expect(first.nextCursor).not.toBeNull(); + const decoded = JSON.parse(Buffer.from(String(first.nextCursor), 'base64url').toString('utf8')); + expect(decoded).toEqual({ identity: PROJECT_PATH, page: 2 }); + + await listChangedFiles(OWNER, PROJECT_PATH, 12, String(first.nextCursor)); + expect(lastFetchUrl().searchParams.get('page')).toBe('2'); + expect(lastFetchUrl().searchParams.get('per_page')).toBe('50'); + }); + + it('never trusts a foreign cursor to switch project: it restarts at page 1', async () => { + const foreign = Buffer.from( + JSON.stringify({ identity: 'victim/project', page: 4 }), + 'utf8' + ).toString('base64url'); + + await listChangedFiles(OWNER, PROJECT_PATH, 12, foreign); + + expect(lastFetchUrl().searchParams.get('page')).toBe('1'); + expect(lastFetchUrl().pathname).toContain(encodeURIComponent(PROJECT_PATH)); + expect(lastFetchUrl().pathname).not.toContain('victim'); + }); +}); + +describe('getFileLines', () => { + it('returns the 1-based inclusive slice with the total line count', async () => { + mockFetchGitLabRootTextFileAtRef.mockResolvedValue('a\nb\nc\nd\ne'); + + const result = await getFileLines(OWNER, PROJECT_PATH, 'sha-head', 'file.txt', 2, 4); + + expect(result).toEqual({ lines: ['b', 'c', 'd'], totalLines: 5 }); + expect(mockFetchGitLabRootTextFileAtRef).toHaveBeenCalledWith( + 'glpat-mock-token', + PROJECT_PATH, + 'file.txt', + 'sha-head', + INSTANCE_URL + ); + }); + + it('refuses a missing file as non-retryable not_found', async () => { + mockFetchGitLabRootTextFileAtRef.mockResolvedValue(null); + + await expect( + getFileLines(OWNER, PROJECT_PATH, 'sha-head', 'gone.txt', 1, 5) + ).rejects.toMatchObject({ kind: 'not_found', retryable: false }); + }); +}); + +describe('listDiscussions', () => { + it('maps discussions to threads with path, line, side, resolvable, and resolved', async () => { + const discussions = [ + { + id: 'disc-1', + individual_note: false, + notes: [ + { + id: 11, + body: 'Guard this parse', + author: { id: 1, username: 'alice', name: 'Alice' }, + created_at: '2026-01-02T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + system: false, + noteable_id: 100, + noteable_type: 'MergeRequest', + noteable_iid: 12, + resolvable: true, + resolved: false, + position: { + base_sha: 'sha-base', + start_sha: 'sha-start', + head_sha: 'sha-head', + old_path: 'src/a.ts', + new_path: 'src/a.ts', + position_type: 'text', + old_line: null, + new_line: 42, + }, + }, + { + id: 12, + body: 'Merged the guard', + author: { id: 2, username: 'bob', name: 'Bob' }, + created_at: '2026-01-02T01:00:00Z', + updated_at: '2026-01-02T01:00:00Z', + system: false, + noteable_id: 100, + noteable_type: 'MergeRequest', + noteable_iid: 12, + resolvable: true, + resolved: false, + }, + ], + }, + { + id: 'disc-2', + individual_note: true, + notes: [ + { + id: 13, + body: 'Overall looks good', + author: { id: 2, username: 'bob', name: 'Bob' }, + created_at: '2026-01-02T02:00:00Z', + updated_at: '2026-01-02T02:00:00Z', + system: false, + noteable_id: 100, + noteable_type: 'MergeRequest', + noteable_iid: 12, + resolvable: false, + }, + ], + }, + { + id: 'disc-3', + individual_note: false, + notes: [ + { + id: 14, + body: 'resolved thread note', + author: { id: 1, username: 'alice', name: 'Alice' }, + created_at: '2026-01-02T03:00:00Z', + updated_at: '2026-01-02T03:00:00Z', + system: false, + noteable_id: 100, + noteable_type: 'MergeRequest', + noteable_iid: 12, + resolvable: true, + resolved: true, + }, + { + id: 15, + body: '', + author: { id: 3, username: 'root', name: 'Root' }, + created_at: '2026-01-02T04:00:00Z', + updated_at: '2026-01-02T04:00:00Z', + system: true, + noteable_id: 100, + noteable_type: 'MergeRequest', + noteable_iid: 12, + resolvable: false, + }, + ], + }, + ]; + fetchMock.mockImplementation(() => Promise.resolve(jsonResponse(discussions))); + + const page = await listDiscussions(OWNER, PROJECT_PATH, 12); + + expect(page.threads).toHaveLength(3); + expect(page.threads[0]).toMatchObject({ + threadId: 'disc-1', + resolved: false, + resolvable: true, + path: 'src/a.ts', + line: 42, + side: 'RIGHT', + }); + expect(page.threads[0]?.comments).toHaveLength(2); + expect(page.threads[1]).toMatchObject({ + threadId: 'disc-2', + resolvable: false, + path: null, + line: null, + side: null, + }); + expect(page.threads[2]).toMatchObject({ threadId: 'disc-3', resolved: true }); + // System notes never appear as comments. + expect(page.threads[2]?.comments.map(comment => comment.commentId)).toEqual(['14']); + }); + + it('reuses the same cursor rule: foreign identity restarts at page 1', async () => { + const foreign = Buffer.from( + JSON.stringify({ identity: 'other/repo', page: 9 }), + 'utf8' + ).toString('base64url'); + + await listDiscussions(OWNER, PROJECT_PATH, 12, foreign); + + expect(lastFetchUrl().searchParams.get('page')).toBe('1'); + }); +}); + +describe('listChecks', () => { + it('lists the MR pipelines endpoint with status and details URL', async () => { + const pipelines = [ + { + id: 41, + sha: 'merge-ref-sha', + ref: 'refs/merge-requests/12/merge', + status: 'running', + web_url: `${INSTANCE_URL}/group/sub/repo/-/pipelines/41`, + name: null, + }, + { + id: 42, + sha: 'sha-head', + ref: 'feature/deploy', + status: 'success', + web_url: `${INSTANCE_URL}/group/sub/repo/-/pipelines/42`, + name: 'e2e', + }, + ]; + fetchMock.mockImplementation(url => { + if (new URL(String(url)).pathname.endsWith('/pipelines')) { + return Promise.resolve(jsonResponse(pipelines)); + } + return Promise.resolve(jsonResponse([])); + }); + + const result = await listChecks(OWNER, PROJECT_PATH, 12); + + // The MR pipelines endpoint is the only listing that includes the MR's + // merge-ref pipelines; the project-wide pipelines-by-sha listing misses + // them (they run on the merge result sha, not the head sha). + const url = lastFetchUrl(); + expect(url.pathname).toBe( + `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/pipelines` + ); + expect(url.searchParams.get('sha')).toBeNull(); + expect(url.searchParams.get('per_page')).toBe('50'); + expect(mockGetMRHeadCommit).not.toHaveBeenCalled(); + expect(result.checks).toEqual([ + { + name: 'refs/merge-requests/12/merge', + status: 'running', + conclusion: null, + detailsUrl: `${INSTANCE_URL}/group/sub/repo/-/pipelines/41`, + }, + { + name: 'e2e', + status: 'success', + conclusion: 'success', + detailsUrl: `${INSTANCE_URL}/group/sub/repo/-/pipelines/42`, + }, + ]); + }); +}); + +describe('listInbox', () => { + it('queries opened merge requests where the acting user is the reviewer', async () => { + mockFetchGitLabUser.mockResolvedValue({ + id: 1, + username: 'reviewer', + name: 'Reviewer', + email: 'r@example.com', + avatar_url: '', + web_url: `${INSTANCE_URL}/reviewer`, + }); + const globalMrs = [ + { + ...mrFixture, + references: { full: 'group/sub/repo!12' }, + }, + { + ...mrFixture, + iid: 99, + references: { full: 'team/other!99' }, + updated_at: '2026-01-04T00:00:00Z', + }, + ]; + fetchMock.mockImplementation(url => { + if (new URL(String(url)).pathname === '/api/v4/merge_requests') { + return Promise.resolve(jsonResponse(globalMrs)); + } + return Promise.resolve(jsonResponse([])); + }); + + const page = await listInbox(OWNER); + + const url = lastFetchUrl(); + expect(url.pathname).toBe('/api/v4/merge_requests'); + // Without a scope the API defaults to authored merge requests + // (`created_by_me`); the inbox must ask for review requests. + expect(url.searchParams.get('scope')).toBe('reviews_for_me'); + expect(url.searchParams.get('reviewer_username')).toBe('reviewer'); + expect(url.searchParams.get('state')).toBe('opened'); + expect(page.items).toHaveLength(2); + expect(page.items[0]?.ref).toEqual({ + platform: 'gitlab', + projectPath: 'group/sub/repo', + mrIid: 12, + instanceHint: INSTANCE_URL, + }); + expect(page.items[1]?.ref).toMatchObject({ projectPath: 'team/other', mrIid: 99 }); + }); + + it('skips rows with no resolvable project path instead of guessing', async () => { + mockFetchGitLabUser.mockResolvedValue({ + id: 1, + username: 'reviewer', + name: 'R', + email: '', + avatar_url: '', + web_url: '', + }); + fetchMock.mockImplementation(url => { + if (new URL(String(url)).pathname === '/api/v4/merge_requests') { + return Promise.resolve( + jsonResponse([{ ...mrFixture, references: undefined, web_url: 'not a url' }]) + ); + } + return Promise.resolve(jsonResponse([])); + }); + + const page = await listInbox(OWNER); + + expect(page.items).toEqual([]); + }); +}); + +describe('getMergeState', () => { + function routeResponses(overrides: { + settings?: Record; + approvals?: unknown; + discussions?: unknown[]; + }) { + fetchMock.mockImplementation(url => { + const path = new URL(String(url)).pathname; + if (path.endsWith('/approvals')) { + return overrides.approvals === undefined + ? Promise.resolve(jsonResponse({ message: '404 Not found' }, 404)) + : Promise.resolve(jsonResponse(overrides.approvals)); + } + if (path.endsWith('/discussions')) { + return Promise.resolve(jsonResponse(overrides.discussions ?? [])); + } + if (path.endsWith('/diffs')) return Promise.resolve(jsonResponse([])); + return Promise.resolve(jsonResponse(overrides.settings ?? {})); + }); + } + + it('reports blocked reasons from settings, approvals, conflicts, and pipeline', async () => { + routeResponses({ + settings: { + only_allow_merge_if_pipeline_succeeds: true, + only_allow_merge_if_all_discussions_are_resolved: true, + }, + approvals: { approvals_required: 3, approvals_left: 2 }, + discussions: [ + { + id: 'd1', + individual_note: false, + notes: [ + { + id: 1, + body: 'x', + author: { id: 1, username: 'a', name: 'A' }, + created_at: '', + updated_at: '', + system: false, + noteable_id: 1, + noteable_type: 'MergeRequest', + noteable_iid: 12, + resolvable: true, + resolved: false, + }, + ], + }, + ], + }); + mockFetchGitLabMergeRequest.mockResolvedValue({ + ...mrFixture, + has_conflicts: true, + merge_status: 'cannot_be_merged', + head_pipeline: { ...mrFixture.head_pipeline, status: 'failed' }, + }); + + const state = await getMergeState(OWNER, PROJECT_PATH, 12); + + expect(state).toMatchObject({ + canMerge: false, + approvalsRequired: 3, + pipelineMustSucceed: true, + conflicts: true, + }); + expect(state.blockedReasons.map(reason => reason.code)).toEqual( + expect.arrayContaining(['conflicts', 'required_approvals', 'failing_pipeline', 'other']) + ); + }); + + it('allows merge when every gate is satisfied', async () => { + routeResponses({ + settings: { + only_allow_merge_if_pipeline_succeeds: true, + only_allow_merge_if_all_discussions_are_resolved: true, + }, + approvals: { approvals_required: 1, approvals_left: 0 }, + discussions: [], + }); + + const state = await getMergeState(OWNER, PROJECT_PATH, 12); + + expect(state).toMatchObject({ + canMerge: true, + approvalsRequired: 1, + pipelineMustSucceed: true, + conflicts: false, + blockedReasons: [], + }); + }); + + it('treats a 404 approvals endpoint (no approval rules) as no approval gate', async () => { + routeResponses({ settings: {}, approvals: undefined }); + + const state = await getMergeState(OWNER, PROJECT_PATH, 12); + + expect(state.approvalsRequired).toBe(0); + expect(state.canMerge).toBe(true); + }); + + it('blocks a draft with the draft reason', async () => { + routeResponses({ settings: {} }); + mockFetchGitLabMergeRequest.mockResolvedValue({ ...mrFixture, draft: true }); + + const state = await getMergeState(OWNER, PROJECT_PATH, 12); + + expect(state.canMerge).toBe(false); + expect(state.blockedReasons.map(reason => reason.code)).toContain('draft'); + }); + + it('finds an unresolved discussion beyond the first page before allowing merge', async () => { + // 100 resolved discussions on page 1 (a full page), one unresolved on + // page 2: a gate that only inspects page 1 would allow the merge. + const resolvedDiscussion = { + id: 'd-resolved', + individual_note: false, + notes: [ + { + id: 1, + body: 'x', + author: { id: 1, username: 'a', name: 'A' }, + created_at: '', + updated_at: '', + system: false, + noteable_id: 1, + noteable_type: 'MergeRequest', + noteable_iid: 12, + resolvable: true, + resolved: true, + }, + ], + }; + const fullPage = Array.from({ length: 100 }, (_, index) => ({ + ...resolvedDiscussion, + id: `d-${index}`, + notes: [{ ...resolvedDiscussion.notes[0], id: index + 1 }], + })); + const unresolvedDiscussion = { + ...resolvedDiscussion, + id: 'd-unresolved', + notes: [{ ...resolvedDiscussion.notes[0], resolved: false }], + }; + fetchMock.mockImplementation(url => { + const parsed = new URL(String(url)); + if (parsed.pathname.endsWith('/discussions')) { + return parsed.searchParams.get('page') === '2' + ? Promise.resolve(jsonResponse([unresolvedDiscussion])) + : Promise.resolve(jsonResponse(fullPage)); + } + if (parsed.pathname.endsWith('/approvals')) { + return Promise.resolve(jsonResponse({ message: '404 Not found' }, 404)); + } + if (parsed.pathname.endsWith('/diffs')) return Promise.resolve(jsonResponse([])); + return Promise.resolve( + jsonResponse({ only_allow_merge_if_all_discussions_are_resolved: true }) + ); + }); + + const state = await getMergeState(OWNER, PROJECT_PATH, 12); + + expect(state.canMerge).toBe(false); + expect(state.blockedReasons).toContainEqual({ + code: 'other', + message: 'Resolve all discussions before merging.', + }); + }); +}); + +describe('provider failures reach the four mobile states', () => { + it('classifies a 5xx diffs response as retryable', async () => { + fetchMock.mockImplementation(() => Promise.resolve(jsonResponse({ message: 'boom' }, 503))); + + const error = await captureRejection(listChangedFiles(OWNER, PROJECT_PATH, 12)); + expect(error).toBeInstanceOf(GitLabReviewError); + expect(error.kind).toBe('retryable'); + expect(error.retryable).toBe(true); + }); + + it('classifies a 404 discussion response as not_found without leaking the instance URL', async () => { + fetchMock.mockImplementation(() => Promise.resolve(jsonResponse({ message: '404' }, 404))); + + const error = await captureRejection(listDiscussions(OWNER, PROJECT_PATH, 12)); + expect(error.kind).toBe('not_found'); + expect(error.retryable).toBe(false); + expect(error.message).not.toContain('gitlab.example.com'); + expect(error.message).not.toContain('glpat-mock-token'); + }); + + it('classifies an adapter 403 throw as forbidden', async () => { + mockFetchGitLabMergeRequest.mockRejectedValue(new Error('GitLab MR fetch failed: 403')); + + await expect(getMergeState(OWNER, PROJECT_PATH, 12)).rejects.toMatchObject({ + kind: 'forbidden', + retryable: false, + }); + }); + + it('classifies a network failure as retryable', async () => { + fetchMock.mockImplementation(() => Promise.reject(new TypeError('fetch failed'))); + + await expect(listChecks(OWNER, PROJECT_PATH, 12)).rejects.toMatchObject({ + kind: 'retryable', + retryable: true, + }); + }); +}); + +/** + * Fake a Node https.request round-trip, mirroring adapter.test.ts's + * mockSelfHostedGitLabResponse: the bound transport never touches global + * fetch, so the response is streamed through a PassThrough. + */ +function mockBoundResponse(args: { status: number; json?: unknown; body?: Buffer }) { + mockHttpsRequest.mockImplementationOnce((_options, callback) => { + const response = new PassThrough() as PassThrough & { + statusCode?: number; + statusMessage?: string; + headers: Record; + }; + response.statusCode = args.status; + response.statusMessage = 'OK'; + response.headers = { 'content-type': 'application/json' }; + const request = new EventEmitter() as EventEmitter & { + write: jest.Mock; + end: jest.Mock; + destroy: jest.Mock; + setTimeout: jest.Mock; + }; + request.write = jest.fn(); + request.destroy = jest.fn(); + request.setTimeout = jest.fn(); + request.end = jest.fn(() => { + callback?.(response as never); + response.end(args.body ?? Buffer.from(JSON.stringify(args.json ?? {}))); + }); + return request as never; + }); +} + +type BoundRequestOptions = Omit & { + servername?: string; + lookup: ( + hostname: string, + options: unknown, + callback: (e: null, a: string, f: number) => void + ) => void; +}; + +describe('request transport binds to the resolved address (no DNS rebinding)', () => { + beforeEach(() => { + mockResolveGitLabUrlSafely.mockImplementation(async (urlString: string) => ({ + url: new URL(urlString), + address: '93.184.216.34', + family: 4, + })); + }); + + it('sends the self-managed request through the pinned transport, not global fetch', async () => { + mockBoundResponse({ status: 200, json: diffFixture }); + + const page = await listChangedFiles(OWNER, PROJECT_PATH, 12); + + expect(page.files).toHaveLength(2); + expect(fetchMock).not.toHaveBeenCalled(); + expect(mockHttpsRequest).toHaveBeenCalledTimes(1); + const options = mockHttpsRequest.mock.calls[0][0] as BoundRequestOptions; + expect(options.hostname).toBe('gitlab.example.com'); + expect(options.path).toContain('/merge_requests/12/diffs'); + // TLS still verifies the original host, and the socket can only ever get + // the address the guard resolved — there is no second DNS lookup. + expect(options.servername).toBe('gitlab.example.com'); + let pinnedAddress = ''; + options.lookup('gitlab.example.com', {}, (_error, address) => { + pinnedAddress = address; + }); + expect(pinnedAddress).toBe('93.184.216.34'); + const headers = options.headers as Record; + expect(headers.authorization ?? headers.Authorization).toBe('Bearer glpat-mock-token'); + }); + + it('refuses the request when the guard rejects the resolved host, before any socket', async () => { + mockResolveGitLabUrlSafely.mockRejectedValue( + new GitLabInstanceUrlError( + 'GitLab instance URL host resolves to an address that is not allowed.' + ) + ); + + await expect(listChangedFiles(OWNER, PROJECT_PATH, 12)).rejects.toMatchObject({ + kind: 'bad_request', + retryable: false, + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(mockHttpsRequest).not.toHaveBeenCalled(); + }); + + it('caps a hostile bound response at the adapter 10 MB limit', async () => { + mockBoundResponse({ status: 200, body: Buffer.alloc(10 * 1024 * 1024 + 1, 0x61) }); + + await expect(listChangedFiles(OWNER, PROJECT_PATH, 12)).rejects.toMatchObject({ + kind: 'retryable', + retryable: true, + }); + }); + + it('classifies a bound 404 as not_found without leaking the instance URL', async () => { + mockBoundResponse({ status: 404, json: { message: '404 Project Not Found' } }); + + const error = await captureRejection(listDiscussions(OWNER, PROJECT_PATH, 12)); + + expect(error.kind).toBe('not_found'); + expect(error.message).not.toContain('gitlab.example.com'); + expect(error.message).not.toContain('glpat-mock-token'); + }); +}); diff --git a/apps/web/src/lib/provider-review/gitlab-read.ts b/apps/web/src/lib/provider-review/gitlab-read.ts new file mode 100644 index 0000000000..ec94dd5415 --- /dev/null +++ b/apps/web/src/lib/provider-review/gitlab-read.ts @@ -0,0 +1,812 @@ +/** + * GitLab merge-request READ layer for the provider review surfaces. + * + * Every function resolves credentials through gitlab-authorization first, so + * the instance URL and token are always server-derived, and returns the shared + * s1 DTOs so a provider difference never leaks past this module. Adapter + * helpers are reused wherever they exist; the remaining GitLab endpoints go + * through the thin JSON request below, which uses the same URL builder, + * DNS-pinned transport, and response cap the adapter applies, so a stored + * self-managed URL cannot re-target the bearer token at another host. + */ +import 'server-only'; + +import * as http from 'http'; +import * as https from 'https'; +import type { + ProviderPrChecksResult, + ProviderPrFile, + ProviderPrFilesPage, + ProviderPrInboxItem, + ProviderPrInboxPage, + ProviderPrMergeBlockedReason, + ProviderPrMergeState, + ProviderPrSummary, + ProviderPrThread, +} from '@kilocode/app-shared/provider-review'; +import { + fetchGitLabMergeRequest, + fetchGitLabRootTextFileAtRef, + fetchGitLabUser, + getMRHeadCommit, + type GitLabDiscussion, + type GitLabMergeRequest, +} from '@/lib/integrations/platforms/gitlab/adapter'; +import { + buildGitLabUrl, + resolveGitLabUrlSafely, + type GitLabResolvedUrl, +} from '@/lib/integrations/platforms/gitlab/instance-url'; +import { + authorizeOwner, + authorizeProject, + classifyGitLabError, + GitLabApiStatusError, + GitLabReviewError, + type GitLabProjectAccess, + type GitLabReviewOwner, +} from './gitlab-authorization'; + +const GITLAB_PAGE_SIZE = 50; +const GITLAB_REQUEST_TIMEOUT_MS = 30_000; +/** Same response cap the adapter applies, so a hostile instance cannot stream unbounded bytes. */ +const MAX_GITLAB_RESPONSE_BYTES = 10 * 1024 * 1024; + +/** The MR detail JSON carries more fields than the adapter's typed subset. */ +type GitLabMergeRequestDetail = GitLabMergeRequest & { + created_at?: string; + updated_at?: string; + project_id?: number; + has_conflicts?: boolean; + merge_status?: string; + head_pipeline?: { id: number; sha: string; ref: string; status: string; web_url: string } | null; + references?: { full?: string }; + // GitLab omits or nulls diff_refs on merge requests without a diff (for + // example an empty repository or an unresolved merge ref), so it cannot be + // trusted the way the adapter's non-optional type claims. + diff_refs?: GitLabMergeRequest['diff_refs'] | null; +}; + +type GitLabDiff = { + old_path: string; + new_path: string; + new_file: boolean; + renamed_file: boolean; + deleted_file: boolean; + diff: string; +}; + +type GitLabPipeline = { + id: number; + sha: string; + ref: string; + status: string; + web_url: string; + name?: string | null; +}; + +type GitLabProjectSettings = { + only_allow_merge_if_pipeline_succeeds?: boolean; + only_allow_merge_if_all_discussions_are_resolved?: boolean; +}; + +type GitLabApprovals = { + approvals_required?: number; + approvals_left?: number; +}; + +/** + * One JSON request against the authorized instance. The instance URL is the + * server-derived one from authorizeProject/authorizeOwner. The URL is resolved + * once with the adapter's guard and the request is then bound to that exact + * resolved address, so a stored self-managed host cannot be DNS-rebound + * between the check and the connect. + */ +export async function requestGitLabJson( + access: { accessToken: string; instanceUrl: string }, + path: string, + request: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + query?: Record; + body?: unknown; + } = {} +): Promise { + const query = request.query + ? (Object.fromEntries( + Object.entries(request.query).filter(([, value]) => value !== undefined) + ) as Record) + : undefined; + const url = buildGitLabUrl(access.instanceUrl, path, query); + try { + const response = await fetchGitLabValidated(url, { + method: request.method ?? 'GET', + headers: { + Authorization: `Bearer ${access.accessToken}`, + Accept: 'application/json', + ...(request.body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: request.body !== undefined ? JSON.stringify(request.body) : undefined, + redirect: 'manual', + signal: AbortSignal.timeout(GITLAB_REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + // Only the status survives — the provider body is never re-emitted. + throw new GitLabApiStatusError( + response.status, + `GitLab ${request.method ?? 'GET'} request failed: ${response.status}` + ); + } + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; + } catch (error) { + throw classifyGitLabError(error); + } +} + +/** + * Resolve the URL once (refusing unsafe hosts), then send the request bound + * to the resolved address. Only an IP literal or gitlab.com keeps the plain + * transport — the same split the adapter's fetchGitLabOnce makes. + */ +async function fetchGitLabValidated(url: string, init: RequestInit): Promise { + const resolvedUrl = await resolveGitLabUrlSafely(url); + if (!resolvedUrl.address) { + return fetch(url, { ...init, redirect: 'manual' }); + } + return fetchGitLabBoundToAddress({ ...resolvedUrl, address: resolvedUrl.address }, init); +} + +/** + * Node-transport mirror of the adapter's fetchGitLabBoundToAddress: the DNS + * answer from resolveGitLabUrlSafely is the only address the socket can + * connect to, TLS keeps the original hostname as SNI, and the response is + * capped. Redirects are never followed (the caller treats a 3xx as an error). + */ +function fetchGitLabBoundToAddress( + { url, address, family }: GitLabResolvedUrl & { address: string }, + init: RequestInit +): Promise { + const request = url.protocol === 'https:' ? https.request : http.request; + const headers = new Headers(init.headers); + const body = typeof init.body === 'string' ? Buffer.from(init.body) : undefined; + if (body && !headers.has('content-length')) { + headers.set('content-length', String(Buffer.byteLength(body))); + } + + return new Promise((resolve, reject) => { + const req = request( + { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + path: `${url.pathname}${url.search}`, + method: init.method ?? 'GET', + headers: Object.fromEntries(headers.entries()), + family, + lookup: (_hostname, _options, callback) => callback(null, address, family ?? 0), + ...(url.protocol === 'https:' ? { servername: url.hostname } : {}), + }, + response => { + const chunks: Buffer[] = []; + let responseBytes = 0; + response.on('data', chunk => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + responseBytes += buffer.byteLength; + if (responseBytes > MAX_GITLAB_RESPONSE_BYTES) { + const error = new Error('GitLab response exceeded size limit'); + response.destroy(error); + req.destroy(error); + reject(error); + return; + } + chunks.push(buffer); + }); + response.on('error', reject); + response.on('end', () => { + try { + const status = response.statusCode ?? 500; + const responseBody = + status === 204 || status === 205 || status === 304 ? null : Buffer.concat(chunks); + const responseHeaders = new Headers(); + for (const [key, value] of Object.entries(response.headers)) { + if (Array.isArray(value)) { + for (const item of value) { + responseHeaders.append(key, item); + } + } else if (value !== undefined) { + responseHeaders.set(key, value); + } + } + resolve( + new Response(responseBody, { + status, + statusText: response.statusMessage, + headers: responseHeaders, + }) + ); + } catch (error) { + reject(error); + } + }); + } + ); + + req.on('error', reject); + req.setTimeout(GITLAB_REQUEST_TIMEOUT_MS, () => { + req.destroy(new Error('GitLab request timed out')); + }); + + const signal = init.signal; + if (signal) { + if (signal.aborted) { + req.destroy(signal.reason); + reject(signal.reason); + return; + } + signal.addEventListener( + 'abort', + () => { + req.destroy(signal.reason); + reject(signal.reason); + }, + { once: true } + ); + } + + if (body) { + req.write(body); + } + req.end(); + }); +} + +function projectSegment(access: Pick): string { + return encodeURIComponent(access.projectPath); +} + +/** + * A page cursor carries the project identity it was minted for. A cursor + * bound to another project is ignored (page 1), so page identity can never + * switch the project a request reads. + */ +function encodePageCursor(identity: string, page: number): string { + return Buffer.from(JSON.stringify({ identity, page })).toString('base64url'); +} + +function decodePageCursor(cursor: string | undefined, identity: string): number { + if (!cursor) return 1; + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { + identity?: unknown; + page?: unknown; + }; + if (typeof parsed.identity !== 'string' || parsed.identity !== identity) return 1; + if (typeof parsed.page !== 'number' || !Number.isInteger(parsed.page) || parsed.page < 1) { + return 1; + } + return parsed.page; + } catch { + return 1; + } +} + +function cursorForPage(identity: string, page: number, returnedCount: number): string | null { + if (returnedCount < GITLAB_PAGE_SIZE) return null; + return encodePageCursor(identity, page + 1); +} + +function mapMergeRequestState(state: GitLabMergeRequest['state']): ProviderPrSummary['state'] { + if (state === 'merged') return 'merged'; + if (state === 'opened') return 'open'; + return 'closed'; +} + +function isDraftMr(mr: GitLabMergeRequestDetail): boolean { + if (typeof mr.draft === 'boolean') return mr.draft; + if (typeof mr.work_in_progress === 'boolean') return mr.work_in_progress; + return /^(draft|wip)\s*[:(-]/i.test(mr.title); +} + +function diffLineCounts(diff: string): { additions: number; deletions: number } { + let additions = 0; + let deletions = 0; + for (const line of diff.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) additions++; + else if (line.startsWith('-') && !line.startsWith('---')) deletions++; + } + return { additions, deletions }; +} + +function mapDiffToFile(diff: GitLabDiff): ProviderPrFile { + const { additions, deletions } = diffLineCounts(diff.diff ?? ''); + const status = diff.new_file + ? 'added' + : diff.deleted_file + ? 'deleted' + : diff.renamed_file + ? 'renamed' + : 'modified'; + return { + path: diff.new_path, + previousPath: diff.old_path !== diff.new_path ? diff.old_path : null, + status, + additions, + deletions, + patch: diff.diff || null, + patchMissing: !diff.diff, + }; +} + +async function fetchDiffPage( + access: GitLabProjectAccess, + mrIid: number, + page: number +): Promise { + return requestGitLabJson( + access, + `/api/v4/projects/${projectSegment(access)}/merge_requests/${mrIid}/diffs`, + { query: { per_page: GITLAB_PAGE_SIZE, page } } + ); +} + +/** + * The MR as the review screen renders it: detail, head sha, and diff refs, + * with change counts folded in from the first diff pages. + */ +export async function getMergeRequest( + owner: GitLabReviewOwner, + projectPath: string, + mrIid: number, + instanceHint?: string +): Promise { + const access = await authorizeProject(owner, projectPath, instanceHint); + try { + // diff_refs are read off the MR response itself: the adapter's + // getMRDiffRefs helper dereferences mr.diff_refs.base_sha and crashes the + // whole detail load when GitLab omits them, while the mapping below + // already tolerates absence. + const [mr, headSha] = await Promise.all([ + fetchGitLabMergeRequest({ + accessToken: access.accessToken, + projectId: access.projectPath, + mrIid, + instanceUrl: access.instanceUrl, + }), + getMRHeadCommit(access.accessToken, access.projectPath, mrIid, access.instanceUrl), + ]); + // Counts come from the diffs; cap the pages so one MR detail load can + // never fan out into an unbounded crawl on a huge merge request. + const files: GitLabDiff[] = []; + for (let page = 1; page <= 3; page++) { + const diffs = await fetchDiffPage(access, mrIid, page); + files.push(...diffs); + if (diffs.length < GITLAB_PAGE_SIZE) break; + } + let additions = 0; + let deletions = 0; + for (const file of files) { + const counts = diffLineCounts(file.diff ?? ''); + additions += counts.additions; + deletions += counts.deletions; + } + const detail = mr as GitLabMergeRequestDetail; + return { + ref: { + platform: 'gitlab', + projectPath: access.projectPath, + mrIid, + instanceHint: access.instanceUrl, + }, + title: detail.title, + body: detail.description ?? null, + author: detail.author + ? { + login: detail.author.username, + avatarUrl: (detail.author as { avatar_url?: string | null }).avatar_url ?? null, + } + : null, + state: mapMergeRequestState(detail.state), + draft: isDraftMr(detail), + headRef: detail.source_branch, + baseRef: detail.target_branch, + // The diff head sha is the fence every write compares against; fall + // back to the detail sha only when diff_refs is absent. + headSha: detail.diff_refs?.head_sha || headSha || detail.sha, + changedFiles: files.length, + additions, + deletions, + webUrl: detail.web_url, + createdAt: detail.created_at ?? detail.updated_at ?? '', + updatedAt: detail.updated_at ?? '', + }; + } catch (error) { + throw classifyGitLabError(error); + } +} + +/** One page of changed files. `cursor` is the opaque page token from a prior call. */ +export async function listChangedFiles( + owner: GitLabReviewOwner, + projectPath: string, + mrIid: number, + cursor?: string, + instanceHint?: string +): Promise { + const access = await authorizeProject(owner, projectPath, instanceHint); + try { + const page = decodePageCursor(cursor, access.projectPath); + const diffs = await fetchDiffPage(access, mrIid, page); + return { + files: diffs.map(mapDiffToFile), + nextCursor: cursorForPage(access.projectPath, page, diffs.length), + }; + } catch (error) { + throw classifyGitLabError(error); + } +} + +export type GitLabFileLines = { + lines: string[]; + totalLines: number; +}; + +/** + * A 1-based inclusive line window of a file at a ref, for comment context. + * A missing file is a non-retryable not_found. + */ +export async function getFileLines( + owner: GitLabReviewOwner, + projectPath: string, + ref: string, + path: string, + startLine: number, + endLine: number, + instanceHint?: string +): Promise { + const access = await authorizeProject(owner, projectPath, instanceHint); + try { + const text = await fetchGitLabRootTextFileAtRef( + access.accessToken, + access.projectPath, + path, + ref, + access.instanceUrl + ); + if (text === null) { + throw new GitLabReviewError('not_found', 'The file was not found at this ref.'); + } + const allLines = text.split('\n'); + const start = Math.max(1, Math.min(startLine, allLines.length)); + const end = Math.max(start, Math.min(endLine, allLines.length)); + return { lines: allLines.slice(start - 1, end), totalLines: allLines.length }; + } catch (error) { + throw classifyGitLabError(error); + } +} + +/** + * A discussion thread. `resolvable` is GitLab-specific (GitHub threads always + * are), so it extends the s1 thread instead of dropping the flag. + */ +export type GitLabDiscussionThread = ProviderPrThread & { resolvable: boolean }; + +export type GitLabDiscussionsPage = { + threads: GitLabDiscussionThread[]; + nextCursor: string | null; +}; + +function mapDiscussion(discussion: GitLabDiscussion): GitLabDiscussionThread { + const firstNote = discussion.notes[0]; + const position = discussion.notes.find(note => note.position)?.position; + const anchorLine = position?.new_line ?? position?.old_line ?? null; + return { + threadId: discussion.id, + resolved: discussion.notes.some(note => note.resolvable) + ? (discussion.notes.find(note => note.resolvable)?.resolved ?? false) + : false, + resolvable: firstNote?.resolvable ?? false, + path: position ? position.new_path || position.old_path : null, + line: anchorLine, + side: position ? (position.new_line != null ? 'RIGHT' : 'LEFT') : null, + comments: discussion.notes + .filter(note => !note.system) + .map(note => ({ + commentId: String(note.id), + author: note.author + ? { + login: note.author.username, + avatarUrl: (note.author as { avatar_url?: string | null }).avatar_url ?? null, + } + : null, + body: note.body, + createdAt: note.created_at, + })), + }; +} + +/** One page of discussions (threads and notes) with their diff anchors. */ +export async function listDiscussions( + owner: GitLabReviewOwner, + projectPath: string, + mrIid: number, + cursor?: string, + instanceHint?: string +): Promise { + const access = await authorizeProject(owner, projectPath, instanceHint); + try { + const page = decodePageCursor(cursor, access.projectPath); + const discussions = await requestGitLabJson( + access, + `/api/v4/projects/${projectSegment(access)}/merge_requests/${mrIid}/discussions`, + { query: { per_page: GITLAB_PAGE_SIZE, page } } + ); + return { + threads: discussions.map(mapDiscussion), + nextCursor: cursorForPage(access.projectPath, page, discussions.length), + }; + } catch (error) { + throw classifyGitLabError(error); + } +} + +const FINISHED_PIPELINE_STATUSES = new Set(['success', 'failed', 'canceled']); + +/** + * The pipelines OF the merge request, as the shared checks DTO. The MR + * pipelines endpoint is the only listing that includes the MR's merge-ref + * pipelines: they run on the merge result sha, so the project-wide + * pipelines-by-sha listing never reports them. + */ +export async function listChecks( + owner: GitLabReviewOwner, + projectPath: string, + mrIid: number, + instanceHint?: string +): Promise { + const access = await authorizeProject(owner, projectPath, instanceHint); + try { + const pipelines = await requestGitLabJson( + access, + `/api/v4/projects/${projectSegment(access)}/merge_requests/${mrIid}/pipelines`, + { query: { per_page: GITLAB_PAGE_SIZE } } + ); + return { + checks: pipelines.map(pipeline => ({ + name: pipeline.name || pipeline.ref, + status: pipeline.status, + conclusion: FINISHED_PIPELINE_STATUSES.has(pipeline.status) ? pipeline.status : null, + detailsUrl: pipeline.web_url, + })), + }; + } catch (error) { + throw classifyGitLabError(error); + } +} + +/** + * Open merge requests awaiting the acting user's review. Each item carries + * platform, project path, and the connected instance origin, so the list can + * never navigate into a different provider's repo. + */ +export async function listInbox( + owner: GitLabReviewOwner, + cursor?: string, + instanceHint?: string +): Promise { + const access = await authorizeOwner(owner, instanceHint); + try { + const me = await fetchGitLabUser(access.accessToken, access.instanceUrl); + const identity = `gitlab-inbox:${owner.type === 'user' ? owner.userId : `${owner.organizationId}:${owner.userId}`}`; + const page = decodePageCursor(cursor, identity); + const mergeRequests = await requestGitLabJson( + access, + '/api/v4/merge_requests', + { + query: { + state: 'opened', + // Without a scope the API defaults to `created_by_me` — authored + // merge requests, not review requests. `reviews_for_me` selects the + // merge requests where the acting user is the reviewer; + // reviewer_username keeps that filter on versions that predate the + // scope value. + scope: 'reviews_for_me', + reviewer_username: me.username, + per_page: GITLAB_PAGE_SIZE, + page, + }, + } + ); + const items: ProviderPrInboxItem[] = []; + for (const mr of mergeRequests) { + const ref = inboxRefFrom(mr, access.instanceUrl); + if (!ref) continue; + items.push({ + ref, + title: mr.title, + author: mr.author + ? { + login: mr.author.username, + avatarUrl: (mr.author as { avatar_url?: string | null }).avatar_url ?? null, + } + : null, + state: mapMergeRequestState(mr.state), + draft: isDraftMr(mr), + updatedAt: mr.updated_at ?? '', + }); + } + return { items, nextCursor: cursorForPage(identity, page, mergeRequests.length) }; + } catch (error) { + throw classifyGitLabError(error); + } +} + +/** + * The project path of a global-MR row: `references.full` is + * `group/sub/repo!123`; fall back to the web URL shape + * `https://host/group/proj/-/merge_requests/123`. A row with neither is + * skipped — an item without a full path could navigate into the wrong repo. + */ +function inboxRefFrom( + mr: GitLabMergeRequestDetail, + instanceUrl: string +): ProviderPrSummary['ref'] | null { + const full = mr.references?.full; + if (full?.includes('!')) { + const [projectPath, iid] = full.split('!'); + const mrIid = Number(iid); + if (projectPath && Number.isInteger(mrIid) && mrIid > 0) { + return { platform: 'gitlab', projectPath, mrIid, instanceHint: instanceUrl }; + } + } + try { + const url = new URL(mr.web_url); + const marker = '/-/merge_requests/'; + const markerIndex = url.pathname.indexOf(marker); + if (markerIndex > 1) { + const mrIid = Number(url.pathname.slice(markerIndex + marker.length)); + if (Number.isInteger(mrIid) && mrIid > 0) { + return { + platform: 'gitlab', + projectPath: decodeURIComponent(url.pathname.slice(1, markerIndex)), + mrIid, + instanceHint: instanceUrl, + }; + } + } + } catch { + // An unparseable web URL falls through to the skip case below. + } + return null; +} + +/** + * The merge gate: branch policy from project settings, approvals from the + * approvals endpoint (absent on plans without approval rules → 0), conflicts + * and pipeline state from the MR detail. + */ +export async function getMergeState( + owner: GitLabReviewOwner, + projectPath: string, + mrIid: number, + instanceHint?: string +): Promise { + const access = await authorizeProject(owner, projectPath, instanceHint); + try { + const mr = (await fetchGitLabMergeRequest({ + accessToken: access.accessToken, + projectId: access.projectPath, + mrIid, + instanceUrl: access.instanceUrl, + })) as GitLabMergeRequestDetail; + const settings = await requestGitLabJson( + access, + `/api/v4/projects/${projectSegment(access)}` + ); + const pipelineMustSucceed = settings.only_allow_merge_if_pipeline_succeeds === true; + const discussionsMustBeResolved = + settings.only_allow_merge_if_all_discussions_are_resolved === true; + + let approvalsRequired = 0; + let approvalsLeft = 0; + try { + const approvals = await requestGitLabJson( + access, + `/api/v4/projects/${projectSegment(access)}/merge_requests/${mrIid}/approvals` + ); + approvalsRequired = approvals.approvals_required ?? 0; + approvalsLeft = approvals.approvals_left ?? 0; + } catch (error) { + // Free/self-managed plans answer 404 when no approval rules exist — + // that means no approval gate, not a missing merge request. + if (!(error instanceof GitLabReviewError) || error.kind !== 'not_found') throw error; + } + + const conflicts = mr.has_conflicts === true || mr.merge_status === 'cannot_be_merged'; + const blockedReasons: ProviderPrMergeBlockedReason[] = []; + if (mr.state !== 'opened') { + blockedReasons.push({ + code: 'other', + message: 'Only open merge requests can be merged.', + }); + } + if (isDraftMr(mr)) { + blockedReasons.push({ code: 'draft', message: 'The merge request is still a draft.' }); + } + if (conflicts) { + blockedReasons.push({ + code: 'conflicts', + message: 'The merge request has conflicts that must be resolved.', + }); + } + if (approvalsLeft > 0) { + blockedReasons.push({ + code: 'required_approvals', + message: `${approvalsLeft} more approval${approvalsLeft === 1 ? '' : 's'} required.`, + }); + } + if (pipelineMustSucceed) { + const pipelineStatus = mr.head_pipeline?.status; + if (pipelineStatus === 'failed' || pipelineStatus === 'canceled') { + blockedReasons.push({ + code: 'failing_pipeline', + message: 'The pipeline on the latest commit failed.', + }); + } else if (pipelineStatus !== 'success') { + blockedReasons.push({ + code: 'pending_pipeline', + message: pipelineStatus + ? 'The pipeline on the latest commit has not finished yet.' + : 'No pipeline was found for the latest commit.', + }); + } + } + if (discussionsMustBeResolved && mr.state === 'opened') { + const unresolved = await hasUnresolvedDiscussions(access, mrIid); + if (unresolved) { + blockedReasons.push({ + code: 'other', + message: 'Resolve all discussions before merging.', + }); + } + } + + return { + canMerge: mr.state === 'opened' && blockedReasons.length === 0, + approvalsRequired, + pipelineMustSucceed, + conflicts, + blockedReasons, + }; + } catch (error) { + throw classifyGitLabError(error); + } +} + +/** + * The discussion walk bound for the merge gate: one merge-state load may + * read at most this many pages of 100 discussions. Past the bound GitLab + * itself enforces the resolved-discussions gate at merge time, so the + * display reason failing open cannot let a merge through. + */ +const MAX_MERGE_GATE_DISCUSSION_PAGES = 20; + +async function hasUnresolvedDiscussions( + access: GitLabProjectAccess, + mrIid: number +): Promise { + for (let page = 1; page <= MAX_MERGE_GATE_DISCUSSION_PAGES; page += 1) { + const discussions = await requestGitLabJson( + access, + `/api/v4/projects/${projectSegment(access)}/merge_requests/${mrIid}/discussions`, + { query: { per_page: 100, page } } + ); + const unresolved = discussions.some(discussion => + discussion.notes.some(note => note.resolvable && note.resolved === false) + ); + if (unresolved) return true; + if (discussions.length < 100) break; + } + return false; +} diff --git a/apps/web/src/lib/provider-review/gitlab-write.test.ts b/apps/web/src/lib/provider-review/gitlab-write.test.ts new file mode 100644 index 0000000000..0cc1f1157f --- /dev/null +++ b/apps/web/src/lib/provider-review/gitlab-write.test.ts @@ -0,0 +1,928 @@ +import { describe, expect, it, beforeEach } from '@jest/globals'; +import type { PlatformIntegration } from '@kilocode/db/schema'; +import type { Owner } from '@/lib/integrations/core/types'; +import { GitLabReviewError } from './gitlab-authorization'; +import { + GITLAB_AUTO_MERGE_NO_PIPELINE_REASON, + GITLAB_MR_REVIEW_CAPABILITIES, + GITLAB_REQUEST_CHANGES_UNSUPPORTED_REASON, + GITLAB_STALE_HEAD_REASON, + addComment, + deleteBranch, + disableAutoMerge, + enableAutoMerge, + mergePullRequest, + replyToDiscussion, + resolveThread, + submitReview, + unresolveThread, +} from './gitlab-write'; + +const mockGetIntegrationForOwner = jest.fn(); +const mockGetValidGitLabToken = jest.fn(); +const mockCreateMRNote = jest.fn(); +const mockFetchGitLabMergeRequest = jest.fn(); + +jest.mock('@/lib/integrations/db/platform-integrations', () => ({ + getIntegrationForOwner: (owner: Owner, platform: string) => + mockGetIntegrationForOwner(owner, platform), +})); + +jest.mock('@/lib/integrations/gitlab-service', () => ({ + getValidGitLabToken: (integration: PlatformIntegration, actor: unknown) => + mockGetValidGitLabToken(integration, actor), +})); + +jest.mock('@/lib/integrations/platforms/gitlab/adapter', () => ({ + createMRNote: (...args: unknown[]) => mockCreateMRNote(...args), + fetchGitLabMergeRequest: (params: unknown) => mockFetchGitLabMergeRequest(params), + fetchGitLabUser: jest.fn(), + fetchGitLabRootTextFileAtRef: jest.fn(), + getMRHeadCommit: jest.fn(), + getMRDiffRefs: jest.fn(), +})); + +jest.mock('@/lib/integrations/platforms/gitlab/instance-url', () => { + const actual = jest.requireActual('@/lib/integrations/platforms/gitlab/instance-url'); + return { + ...actual, + // No pinned address → requests keep the plain fetch transport these + // assertions read; the bound transport is covered in gitlab-read.test.ts. + resolveGitLabUrlSafely: jest.fn(async (urlString: string) => ({ + url: new URL(urlString), + })), + }; +}); + +const OWNER: { type: 'user'; userId: string } = { + type: 'user', + userId: 'user_1', +}; +const INSTANCE_URL = 'https://gitlab.example.com'; +const PROJECT_PATH = 'group/sub/repo'; + +/** Await a rejection and return it typed, without a success-branch union. */ +async function captureRejection(promise: Promise): Promise { + try { + await promise; + } catch (reason) { + return reason as GitLabReviewError; + } + throw new Error('Expected the call to reject.'); +} +const TARGET = { owner: OWNER, projectPath: PROJECT_PATH, mrIid: 12 }; + +const integrationRow = { + id: 'intg_1', + platform: 'gitlab', + integration_status: 'active', + owned_by_user_id: 'user_1', + owned_by_organization_id: null, + metadata: { gitlab_instance_url: INSTANCE_URL }, + repositories: [{ id: 7, name: 'repo', full_name: PROJECT_PATH, private: true }], +} as unknown as PlatformIntegration; + +function openMrFixture(headSha: string, extra: Record = {}) { + return { + id: 100, + iid: 12, + title: 'Add nested deploy script', + description: null, + state: 'opened', + draft: false, + source_branch: 'feature/deploy', + target_branch: 'main', + sha: headSha, + diff_refs: { + base_sha: 'sha-base', + head_sha: headSha, + start_sha: 'sha-start', + }, + web_url: `${INSTANCE_URL}/group/sub/repo/-/merge_requests/12`, + author: { id: 1, username: 'alice', name: 'Alice' }, + ...extra, + }; +} + +let fetchMock: jest.Mock; + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function lastRequest(): { url: URL; init: RequestInit } { + const last = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; + return { + url: new URL(String(last[0])), + init: (last[1] ?? {}) as RequestInit, + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockGetIntegrationForOwner.mockResolvedValue(integrationRow); + mockGetValidGitLabToken.mockResolvedValue('glpat-mock-token'); + mockCreateMRNote.mockResolvedValue(undefined); + mockFetchGitLabMergeRequest.mockResolvedValue(openMrFixture('sha-head')); + fetchMock = jest.fn().mockResolvedValue(jsonResponse({ state: 'opened' })); + globalThis.fetch = fetchMock as unknown as typeof fetch; +}); + +describe('addComment / replyToDiscussion', () => { + it('posts a project note with the server-derived credentials', async () => { + const result = await addComment({ + ...TARGET, + body: 'Ship it', + operationKey: 'op-1', + }); + + expect(result).toEqual({ done: true, replayed: false }); + expect(mockCreateMRNote).toHaveBeenCalledWith( + 'glpat-mock-token', + PROJECT_PATH, + 12, + 'Ship it', + INSTANCE_URL + ); + }); + + it('without an anchor keeps the note path and fetches no diff refs', async () => { + await addComment({ ...TARGET, body: 'Ship it' }); + + expect(mockCreateMRNote).toHaveBeenCalledTimes(1); + expect(mockFetchGitLabMergeRequest).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('replies inside a discussion thread', async () => { + const result = await replyToDiscussion({ + ...TARGET, + discussionId: 'disc-1', + body: 'Fixed', + operationKey: 'op-2', + }); + + expect(result).toEqual({ done: true, replayed: false }); + const { url, init } = lastRequest(); + expect(init.method).toBe('POST'); + expect(url.pathname).toBe( + `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/discussions/disc-1/notes` + ); + expect(JSON.parse(String(init.body))).toEqual({ body: 'Fixed' }); + }); +}); + +describe('addComment with an anchor (diff discussion)', () => { + const discussionsPath = `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/discussions`; + + function discussionRequest(): { url: URL; init: RequestInit } { + const call = fetchMock.mock.calls.find( + entry => new URL(String(entry[0])).pathname === discussionsPath + ); + if (!call) throw new Error('Expected a POST to the discussions endpoint.'); + return { + url: new URL(String(call[0])), + init: (call[1] ?? {}) as RequestInit, + }; + } + + it('RIGHT anchor creates a text-position discussion from the MR diff refs', async () => { + const result = await addComment({ + ...TARGET, + body: 'Guard the path', + anchor: { path: 'deploy/run.sh', side: 'RIGHT', line: 42 }, + }); + + expect(result).toEqual({ done: true, replayed: false }); + // An anchored comment is a real discussion, never a top-level note: + expect(mockCreateMRNote).not.toHaveBeenCalled(); + const { url, init } = discussionRequest(); + expect(init.method).toBe('POST'); + expect(url.pathname).toBe(discussionsPath); + expect(JSON.parse(String(init.body))).toEqual({ + body: 'Guard the path', + position: { + position_type: 'text', + base_sha: 'sha-base', + start_sha: 'sha-start', + head_sha: 'sha-head', + new_path: 'deploy/run.sh', + old_path: 'deploy/run.sh', + new_line: 42, + }, + }); + }); + + it('LEFT anchor positions on the old side with old_line', async () => { + await addComment({ + ...TARGET, + body: 'Deleted too early', + anchor: { path: 'deploy/run.sh', side: 'LEFT', line: 7 }, + }); + + const { init } = discussionRequest(); + const position = (JSON.parse(String(init.body)) as { position: Record }) + .position; + expect(position).toEqual({ + position_type: 'text', + base_sha: 'sha-base', + start_sha: 'sha-start', + head_sha: 'sha-head', + new_path: 'deploy/run.sh', + old_path: 'deploy/run.sh', + old_line: 7, + }); + }); + + it('a RIGHT startLine range anchors new_line alone: an old_line pair 400s on added lines', async () => { + await addComment({ + ...TARGET, + body: 'This block', + anchor: { path: 'a/b.ts', side: 'RIGHT', line: 20, startLine: 10 }, + }); + + const { init } = discussionRequest(); + const position = (JSON.parse(String(init.body)) as { position: Record }) + .position; + // GitLab reads an old_line beside new_line as one changed-line pair, so + // a range on added lines has no old-side counterpart and the position is + // rejected (400). The range anchors its end line on the new side. + expect(position).toEqual({ + position_type: 'text', + base_sha: 'sha-base', + start_sha: 'sha-start', + head_sha: 'sha-head', + new_path: 'a/b.ts', + old_path: 'a/b.ts', + new_line: 20, + }); + expect(position).not.toHaveProperty('old_line'); + }); + + it('refuses an anchor when the MR reports no diff refs, before any write', async () => { + mockFetchGitLabMergeRequest.mockResolvedValue({ + ...openMrFixture('sha-head'), + diff_refs: undefined, + }); + + const error = await captureRejection( + addComment({ + ...TARGET, + body: 'x', + anchor: { path: 'a.ts', side: 'RIGHT', line: 1 }, + }) + ); + + expect(error.kind).toBe('bad_request'); + expect(error.retryable).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('classifies a provider 400 (line outside the diff) as bad_request', async () => { + fetchMock.mockResolvedValue(jsonResponse({ message: '400 Bad request' }, 400)); + + const error = await captureRejection( + addComment({ + ...TARGET, + body: 'x', + anchor: { path: 'a.ts', side: 'RIGHT', line: 999_999 }, + }) + ); + + expect(error).toBeInstanceOf(GitLabReviewError); + expect(error.kind).toBe('bad_request'); + expect(error.retryable).toBe(false); + }); +}); + +describe('submitReview', () => { + it('approve posts the approval plus an optional summary note', async () => { + const result = await submitReview({ + ...TARGET, + event: 'approve', + body: 'LGTM', + }); + + expect(result).toEqual({ done: true, replayed: false }); + const { url, init } = lastRequest(); + expect(url.pathname).toBe( + `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/approve` + ); + expect(init.method).toBe('POST'); + expect(mockCreateMRNote).toHaveBeenCalledWith( + 'glpat-mock-token', + PROJECT_PATH, + 12, + 'LGTM', + INSTANCE_URL + ); + }); + + it('approve without a body posts no note', async () => { + await submitReview({ ...TARGET, event: 'approve' }); + + expect(mockCreateMRNote).not.toHaveBeenCalled(); + }); + + it('comment posts a note and never calls approve', async () => { + await submitReview({ ...TARGET, event: 'comment', body: 'Nit' }); + + expect(mockCreateMRNote).toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('request_changes is refused with the exact reason and no provider call', async () => { + const error = await captureRejection( + submitReview({ ...TARGET, event: 'request_changes', body: 'Nope' }) + ); + + expect(error).toBeInstanceOf(GitLabReviewError); + expect(error.kind).toBe('bad_request'); + expect(error.retryable).toBe(false); + expect(error.message).toBe(GITLAB_REQUEST_CHANGES_UNSUPPORTED_REASON); + // Never a silent fallback to another event: + expect(mockCreateMRNote).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('the capability list excludes request_changes', () => { + expect(GITLAB_MR_REVIEW_CAPABILITIES.reviewEvents).toEqual(['approve', 'comment']); + expect(GITLAB_MR_REVIEW_CAPABILITIES.reviewEvents).not.toContain('request_changes'); + }); +}); + +describe('submitReview with an inline comment batch', () => { + function effectOrder(): string[] { + const order: string[] = []; + fetchMock.mockImplementation(async (url: string | URL) => { + const path = new URL(String(url)).pathname; + if (path.endsWith('/discussions')) order.push('discussion'); + else if (path.endsWith('/approve')) order.push('approve'); + return jsonResponse({ state: 'opened' }); + }); + mockCreateMRNote.mockImplementation(async () => { + order.push('note'); + }); + return order; + } + + function discussionBodies(): Array> { + return fetchMock.mock.calls + .filter(entry => new URL(String(entry[0])).pathname.endsWith('/discussions')) + .map(entry => JSON.parse(String((entry[1] as RequestInit).body))); + } + + it('posts every inline discussion before the approval and the summary note', async () => { + const order = effectOrder(); + + const result = await submitReview({ + ...TARGET, + event: 'approve', + body: 'LGTM', + comments: [ + { path: 'a.ts', side: 'RIGHT', line: 3, body: 'first inline' }, + { + path: 'b.ts', + side: 'LEFT', + line: 9, + startLine: 4, + body: 'second inline', + }, + ], + }); + + expect(result).toEqual({ done: true, replayed: false }); + expect(order).toEqual(['discussion', 'discussion', 'approve', 'note']); + expect(discussionBodies()).toEqual([ + { + body: 'first inline', + position: { + position_type: 'text', + base_sha: 'sha-base', + start_sha: 'sha-start', + head_sha: 'sha-head', + new_path: 'a.ts', + old_path: 'a.ts', + new_line: 3, + }, + }, + { + body: 'second inline', + position: { + position_type: 'text', + base_sha: 'sha-base', + start_sha: 'sha-start', + head_sha: 'sha-head', + new_path: 'b.ts', + old_path: 'b.ts', + old_line: 9, + }, + }, + ]); + }); + + it('a comment event with a batch and no body posts the discussions only', async () => { + const order = effectOrder(); + + const result = await submitReview({ + ...TARGET, + event: 'comment', + comments: [{ path: 'a.ts', side: 'RIGHT', line: 3, body: 'inline only' }], + }); + + expect(result).toEqual({ done: true, replayed: false }); + expect(order).toEqual(['discussion']); + expect(mockCreateMRNote).not.toHaveBeenCalled(); + }); + + it('a mid-batch rejection after a committed discussion reports the ambiguous retryable kind', async () => { + let discussions = 0; + fetchMock.mockImplementation(async (url: string | URL) => { + const path = new URL(String(url)).pathname; + if (path.endsWith('/discussions')) { + discussions += 1; + return discussions === 1 + ? jsonResponse({ id: 'disc-1' }) + : jsonResponse({ message: '400 line is not in diff' }, 400); + } + return jsonResponse({ state: 'opened' }); + }); + + const error = await captureRejection( + submitReview({ + ...TARGET, + event: 'approve', + body: 'LGTM', + comments: [ + { path: 'a.ts', side: 'RIGHT', line: 3, body: 'first' }, + { path: 'b.ts', side: 'RIGHT', line: 4, body: 'outside' }, + ], + }) + ); + + // The first discussion already committed: a deterministic bad_request + // would settle the ledger row failed, and the client's key-rotating + // retry would re-post that comment as a duplicate. The retryable kind + // keeps the row reconcile_pending instead. + expect(error.kind).toBe('retryable'); + expect(error.retryable).toBe(true); + // The failure stops the batch at the rejected item: no approval, no note. + expect(discussions).toBe(2); + expect( + fetchMock.mock.calls.some(entry => new URL(String(entry[0])).pathname.endsWith('/approve')) + ).toBe(false); + expect(mockCreateMRNote).not.toHaveBeenCalled(); + }); + + it('a rejection on the first item, with nothing committed, keeps the deterministic bad_request', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const path = new URL(String(url)).pathname; + if (path.endsWith('/discussions')) { + return jsonResponse({ message: '400 line is not in diff' }, 400); + } + return jsonResponse({ state: 'opened' }); + }); + + const error = await captureRejection( + submitReview({ + ...TARGET, + event: 'approve', + body: 'LGTM', + comments: [ + { path: 'a.ts', side: 'RIGHT', line: 999_999, body: 'outside' }, + { path: 'b.ts', side: 'RIGHT', line: 4, body: 'second' }, + ], + }) + ); + + expect(error.kind).toBe('bad_request'); + expect(error.retryable).toBe(false); + // The batch stops at the refused item: the second discussion never posts. + expect( + fetchMock.mock.calls.filter(entry => + new URL(String(entry[0])).pathname.endsWith('/discussions') + ) + ).toHaveLength(1); + }); + + it('an approval rejection after the whole batch committed is a partial apply too', async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + const path = new URL(String(url)).pathname; + if (path.endsWith('/approve')) { + return jsonResponse({ message: '403 Forbidden' }, 403); + } + return jsonResponse({ id: 'disc-1' }); + }); + + const error = await captureRejection( + submitReview({ + ...TARGET, + event: 'approve', + body: 'LGTM', + comments: [{ path: 'a.ts', side: 'RIGHT', line: 3, body: 'first' }], + }) + ); + + // The inline discussion committed before the approval was refused: a + // failed settle would let the retry re-post it as a duplicate. + expect(error.kind).toBe('retryable'); + expect(error.retryable).toBe(true); + expect(mockCreateMRNote).not.toHaveBeenCalled(); + }); +}); + +describe('resolveThread / unresolveThread', () => { + function discussionFixture(resolved: boolean) { + return { + id: 'disc-1', + individual_note: false, + notes: [ + { + id: 11, + body: 'Guard this', + author: { id: 1, username: 'alice', name: 'Alice' }, + created_at: '', + updated_at: '', + system: false, + noteable_id: 100, + noteable_type: 'MergeRequest', + noteable_iid: 12, + resolvable: true, + resolved, + }, + ], + }; + } + + it('PUTs the discussion resolved flag', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(discussionFixture(false))) + .mockResolvedValueOnce(jsonResponse(discussionFixture(true))); + + const result = await resolveThread({ ...TARGET, discussionId: 'disc-1' }); + + expect(result).toEqual({ done: true, replayed: false }); + const { url, init } = lastRequest(); + expect(init.method).toBe('PUT'); + expect(url.pathname).toBe( + `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/discussions/disc-1` + ); + expect(url.searchParams.get('resolved')).toBe('true'); + }); + + it('reports replayed without a write when the thread is already resolved', async () => { + fetchMock.mockResolvedValue(jsonResponse(discussionFixture(true))); + + const result = await resolveThread({ ...TARGET, discussionId: 'disc-1' }); + + expect(result).toEqual({ done: true, replayed: true }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('unresolveThread clears the flag', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(discussionFixture(true))) + .mockResolvedValueOnce(jsonResponse(discussionFixture(false))); + + const result = await unresolveThread({ ...TARGET, discussionId: 'disc-1' }); + + expect(result).toEqual({ done: true, replayed: false }); + expect(lastRequest().url.searchParams.get('resolved')).toBe('false'); + }); + + it('refuses to resolve a non-resolvable discussion', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + id: 'disc-9', + individual_note: true, + notes: [ + { + id: 20, + body: 'note', + author: { id: 1, username: 'alice', name: 'Alice' }, + created_at: '', + updated_at: '', + system: false, + noteable_id: 100, + noteable_type: 'MergeRequest', + noteable_iid: 12, + resolvable: false, + }, + ], + }) + ); + + await expect(resolveThread({ ...TARGET, discussionId: 'disc-9' })).rejects.toMatchObject({ + kind: 'bad_request', + retryable: false, + }); + }); +}); + +describe('mergePullRequest', () => { + it('re-fetches the MR, fences the head, and merges the exact revision', async () => { + const result = await mergePullRequest({ + ...TARGET, + expectedHeadSha: 'sha-head', + squash: true, + shouldRemoveSourceBranch: true, + operationKey: 'op-merge', + }); + + expect(result).toEqual({ done: true, replayed: false }); + expect(mockFetchGitLabMergeRequest).toHaveBeenCalled(); + const { url, init } = lastRequest(); + expect(init.method).toBe('PUT'); + expect(url.pathname).toBe( + `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/merge` + ); + expect(JSON.parse(String(init.body))).toEqual({ + sha: 'sha-head', + squash: true, + should_remove_source_branch: true, + }); + }); + + it('refuses a stale revision with the exact reason and never merges', async () => { + mockFetchGitLabMergeRequest.mockResolvedValue(openMrFixture('sha-moved')); + + const error = await captureRejection( + mergePullRequest({ ...TARGET, expectedHeadSha: 'sha-head' }) + ); + + expect(error).toBeInstanceOf(GitLabReviewError); + expect(error.kind).toBe('stale_head'); + expect(error.retryable).toBe(false); + expect(error.message).toBe(GITLAB_STALE_HEAD_REASON); + // No merge effect, no redirect to the new head: + const mergeCall = fetchMock.mock.calls.find(call => String(call[0]).endsWith('/merge')); + expect(mergeCall).toBeUndefined(); + }); + + it('reports replayed when the MR is already merged', async () => { + mockFetchGitLabMergeRequest.mockResolvedValue({ + ...openMrFixture('sha-head'), + state: 'merged', + }); + + const result = await mergePullRequest({ + ...TARGET, + expectedHeadSha: 'sha-head', + }); + + expect(result).toEqual({ done: true, replayed: true }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refuses a closed MR with a non-retryable bad_request', async () => { + mockFetchGitLabMergeRequest.mockResolvedValue({ + ...openMrFixture('sha-head'), + state: 'closed', + }); + + await expect( + mergePullRequest({ ...TARGET, expectedHeadSha: 'sha-head' }) + ).rejects.toMatchObject({ kind: 'bad_request', retryable: false }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('surfaces a provider 409 as the same stale-head reason', async () => { + fetchMock.mockResolvedValue(jsonResponse({ message: 'Branch cannot be merged' }, 409)); + + const error = await captureRejection( + mergePullRequest({ ...TARGET, expectedHeadSha: 'sha-head' }) + ); + + expect(error.kind).toBe('stale_head'); + expect(error.message).toBe( + 'The merge request changed since it was loaded. Reload the merge request and try again.' + ); + }); +}); + +describe('enableAutoMerge', () => { + it('arms merge-when-pipeline-succeeds through the merge endpoint with the head fence as sha', async () => { + mockFetchGitLabMergeRequest.mockResolvedValue( + openMrFixture('sha-head', { head_pipeline: { status: 'running' } }) + ); + + const result = await enableAutoMerge({ + ...TARGET, + expectedHeadSha: 'sha-head', + }); + + expect(result).toEqual({ done: true, replayed: false }); + const { url, init } = lastRequest(); + expect(init.method).toBe('PUT'); + // The plain update endpoint silently ignores this attribute, so the + // request must hit /merge (GitLab docs: merge when pipeline succeeds), + // and the caller's head fence travels as `sha`. + expect(url.pathname).toBe( + `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/merge` + ); + expect(JSON.parse(String(init.body))).toEqual({ + merge_when_pipeline_succeeds: true, + sha: 'sha-head', + }); + }); + + it('reports replayed when auto-merge is already enabled', async () => { + mockFetchGitLabMergeRequest.mockResolvedValue( + openMrFixture('sha-head', { merge_when_pipeline_succeeds: true }) + ); + + const result = await enableAutoMerge({ + ...TARGET, + expectedHeadSha: 'sha-head', + }); + + expect(result).toEqual({ done: true, replayed: true }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refuses an already-armed auto-merge on a moved head instead of replaying', async () => { + mockFetchGitLabMergeRequest.mockResolvedValue( + openMrFixture('sha-moved', { merge_when_pipeline_succeeds: true }) + ); + + const error = await captureRejection( + enableAutoMerge({ ...TARGET, expectedHeadSha: 'sha-head' }) + ); + + // The head-sha fence guards the replay too: a moved head must never be + // told "already armed" — it must reload the merge request first. + expect(error.kind).toBe('stale_head'); + expect(error.message).toBe(GITLAB_STALE_HEAD_REASON); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('treats a pipeline waiting for resources as active and arms MWPS', async () => { + mockFetchGitLabMergeRequest.mockResolvedValue( + openMrFixture('sha-head', { head_pipeline: { status: 'waiting_for_resource' } }) + ); + + const result = await enableAutoMerge({ ...TARGET, expectedHeadSha: 'sha-head' }); + + // GitLab's status is `waiting_for_resource` (singular): a pipeline in + // that state can still succeed, so auto-merge must be armable on it. + expect(result).toEqual({ done: true, replayed: false }); + const { url, init } = lastRequest(); + expect(init.method).toBe('PUT'); + expect(url.pathname).toBe( + `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/merge` + ); + expect(JSON.parse(String(init.body))).toEqual({ + merge_when_pipeline_succeeds: true, + sha: 'sha-head', + }); + }); + + it('refuses a stale head with the exact reason and never arms', async () => { + mockFetchGitLabMergeRequest.mockResolvedValue( + openMrFixture('sha-moved', { head_pipeline: { status: 'running' } }) + ); + + const error = await captureRejection( + enableAutoMerge({ ...TARGET, expectedHeadSha: 'sha-head' }) + ); + + expect(error.kind).toBe('stale_head'); + expect(error.message).toBe(GITLAB_STALE_HEAD_REASON); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refuses an MR with no pipeline instead of letting GitLab merge immediately', async () => { + mockFetchGitLabMergeRequest.mockResolvedValue( + openMrFixture('sha-head', { head_pipeline: null }) + ); + + const error = await captureRejection( + enableAutoMerge({ ...TARGET, expectedHeadSha: 'sha-head' }) + ); + + expect(error).toBeInstanceOf(GitLabReviewError); + expect(error.kind).toBe('bad_request'); + expect(error.retryable).toBe(false); + expect(error.message).toBe(GITLAB_AUTO_MERGE_NO_PIPELINE_REASON); + const mergeCall = fetchMock.mock.calls.find(call => String(call[0]).endsWith('/merge')); + expect(mergeCall).toBeUndefined(); + }); + + it('refuses when the latest pipeline already finished', async () => { + mockFetchGitLabMergeRequest.mockResolvedValue( + openMrFixture('sha-head', { head_pipeline: { status: 'success' } }) + ); + + await expect(enableAutoMerge({ ...TARGET, expectedHeadSha: 'sha-head' })).rejects.toMatchObject( + { + kind: 'bad_request', + message: GITLAB_AUTO_MERGE_NO_PIPELINE_REASON, + } + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('disableAutoMerge', () => { + it('cancels through the dedicated cancel endpoint, not the update endpoint', async () => { + mockFetchGitLabMergeRequest.mockResolvedValue( + openMrFixture('sha-head', { merge_when_pipeline_succeeds: true }) + ); + + const result = await disableAutoMerge({ ...TARGET }); + + expect(result).toEqual({ done: true, replayed: false }); + const { url, init } = lastRequest(); + // The plain update endpoint does not accept the attribute: a PUT there + // would report success while auto-merge stays armed. + expect(init.method).toBe('POST'); + expect(url.pathname).toBe( + `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/cancel_merge_when_pipeline_succeeds` + ); + expect(init.body).toBeUndefined(); + }); + + it('reports replayed when auto-merge is not armed', async () => { + const result = await disableAutoMerge({ ...TARGET }); + + expect(result).toEqual({ done: true, replayed: true }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('fences a stale head when the caller provides one', async () => { + mockFetchGitLabMergeRequest.mockResolvedValue( + openMrFixture('sha-moved', { merge_when_pipeline_succeeds: true }) + ); + + await expect( + disableAutoMerge({ ...TARGET, expectedHeadSha: 'sha-head' }) + ).rejects.toMatchObject({ kind: 'stale_head' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('deleteBranch', () => { + it('deletes the project branch', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + + const result = await deleteBranch({ + ...TARGET, + branchName: 'feature/deploy', + }); + + expect(result).toEqual({ done: true, replayed: false }); + const { url, init } = lastRequest(); + expect(init.method).toBe('DELETE'); + expect(url.pathname).toBe( + `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/repository/branches/${encodeURIComponent('feature/deploy')}` + ); + }); + + it('treats an already-deleted branch as a replay', async () => { + fetchMock.mockResolvedValue(jsonResponse({ message: '404 Branch Not Found' }, 404)); + + const result = await deleteBranch({ + ...TARGET, + branchName: 'feature/gone', + }); + + expect(result).toEqual({ done: true, replayed: true }); + }); +}); + +describe('mutation failures reach the four mobile states', () => { + it('classifies a 403 approve as non-retryable forbidden and leaks nothing', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ message: '403 Forbidden — token glpat-secret denied' }, 403) + ); + + const error = await captureRejection(submitReview({ ...TARGET, event: 'approve' })); + + expect(error.kind).toBe('forbidden'); + expect(error.retryable).toBe(false); + expect(error.message).not.toContain('glpat-secret'); + expect(error.message).not.toContain('gitlab.example.com'); + }); + + it('classifies a 5xx as retryable', async () => { + fetchMock.mockResolvedValue(jsonResponse({ message: 'boom' }, 502)); + + await expect( + replyToDiscussion({ ...TARGET, discussionId: 'd', body: 'x' }) + ).rejects.toMatchObject({ kind: 'retryable', retryable: true }); + }); + + it('classifies a network failure on a provider call as retryable', async () => { + fetchMock.mockRejectedValue(new TypeError('fetch failed')); + + const error = await captureRejection( + replyToDiscussion({ ...TARGET, discussionId: 'd', body: 'x' }) + ); + + expect(error.kind).toBe('retryable'); + expect(error.retryable).toBe(true); + }); +}); diff --git a/apps/web/src/lib/provider-review/gitlab-write.ts b/apps/web/src/lib/provider-review/gitlab-write.ts new file mode 100644 index 0000000000..ebfd21fcdc --- /dev/null +++ b/apps/web/src/lib/provider-review/gitlab-write.ts @@ -0,0 +1,567 @@ +/** + * GitLab merge-request WRITE layer for the provider review surfaces. + * + * Every mutation resolves credentials through gitlab-authorization (the + * instance URL and token are server-derived), fences against the caller's + * expected head sha where a revision matters, and returns an idempotent-ready + * `{ done, replayed }` result: `replayed` is true when the provider already + * holds the target state, so the s4 router can run the call through the + * operation ledger (github-pr-review-router.ts:762-779) without a duplicate + * effect. `operationKey` is accepted for that ledger; this layer performs no + * ledger writes itself. + */ +import 'server-only'; + +import type { + ProviderReviewCapabilities, + ProviderReviewInlineAnchor, + ProviderReviewInlineComment, +} from '@kilocode/app-shared/provider-review'; +import { + createMRNote, + fetchGitLabMergeRequest, + type GitLabDiscussion, + type GitLabMergeRequest, +} from '@/lib/integrations/platforms/gitlab/adapter'; +import { + authorizeProject, + classifyGitLabError, + GitLabReviewError, + type GitLabProjectAccess, + type GitLabReviewOwner, +} from './gitlab-authorization'; +import { requestGitLabJson } from './gitlab-read'; + +/** The MR a write acts on. `instanceHint` is display/matching only. */ +export type GitLabMrTarget = { + owner: GitLabReviewOwner; + projectPath: string; + mrIid: number; + instanceHint?: string; +}; + +/** Every mutation accepts the router's ledger key and reports its outcome. */ +export type GitLabMutationInput = { operationKey?: string }; + +export type GitLabMutationResult = { + done: boolean; + /** True when the provider already held the target state — nothing changed. */ + replayed: boolean; +}; + +/** + * The exact reason request-changes is refused: GitLab has no such review + * event, so callers show this instead of silently falling back to a comment. + */ +export const GITLAB_REQUEST_CHANGES_UNSUPPORTED_REASON = + 'GitLab merge requests do not support request-changes reviews. Post a comment instead.'; + +/** + * The stale-head fence reason, shared with classifyGitLabStatus so a locally + * detected moved head and a provider 409 read identically on mobile. + */ +export const GITLAB_STALE_HEAD_REASON = + 'The merge request changed since it was loaded. Reload the merge request and try again.'; + +/** + * The exact reason arming auto-merge is refused on an MR without an active + * pipeline: GitLab's merge endpoint with `merge_when_pipeline_succeeds` and + * no waiting pipeline merges immediately, so arming must never take that + * fall-through path. + */ +export const GITLAB_AUTO_MERGE_NO_PIPELINE_REASON = + 'GitLab arms auto-merge only while a pipeline is running. This merge request has no running pipeline. Start a pipeline, then try again.'; + +/** + * The GitLab capability list for review surfaces. It excludes + * `request_changes` from `reviewEvents` (the provider has no such event); + * the app-shared GITLAB_REVIEW_CAPABILITIES constant still lists it, so the + * s4 router must surface this list for GitLab, not the generic one. + */ +export const GITLAB_MR_REVIEW_CAPABILITIES: ProviderReviewCapabilities = { + canComment: true, + reviewEvents: ['approve', 'comment'], + canResolveThreads: true, + canMerge: true, + autoMerge: { supported: true, reason: '' }, + reactions: { supported: true, reason: '' }, + reviewStatus: { supported: true, reason: '' }, +}; + +type GitLabMergeRequestDetail = GitLabMergeRequest & { + merge_when_pipeline_succeeds?: boolean; + force_remove_source_branch?: boolean; + head_pipeline?: { status?: string } | null; +}; + +/** + * Pipeline states that can still succeed. Any other state (no pipeline, a + * terminal state, a manual one) means GitLab's merge endpoint would merge + * immediately instead of waiting, so auto-merge cannot be armed on it. + */ +const GITLAB_ACTIVE_PIPELINE_STATUSES = new Set([ + 'created', + 'waiting_for_resource', + 'waiting', + 'pending', + 'running', + 'scheduled', + 'preparing', + 'completing', +]); + +function hasActivePipeline(mr: GitLabMergeRequestDetail): boolean { + return ( + typeof mr.head_pipeline?.status === 'string' && + GITLAB_ACTIVE_PIPELINE_STATUSES.has(mr.head_pipeline.status) + ); +} + +async function targetAccess(target: GitLabMrTarget): Promise { + return authorizeProject(target.owner, target.projectPath, target.instanceHint); +} + +function mrPath(access: GitLabProjectAccess, mrIid: number): string { + return `/api/v4/projects/${encodeURIComponent(access.projectPath)}/merge_requests/${mrIid}`; +} + +/** + * The MR's diff refs, fetched server-side through the authorized access. A + * diff discussion positions against base/start/head, so an anchored comment + * can only be built from the revision the provider reports right now. + */ +type GitLabDiffRefs = { base_sha: string; head_sha: string; start_sha: string }; + +async function fetchMrDiffRefs( + access: GitLabProjectAccess, + mrIid: number +): Promise { + const mr = (await fetchGitLabMergeRequest({ + accessToken: access.accessToken, + projectId: access.projectPath, + mrIid, + instanceUrl: access.instanceUrl, + })) as GitLabMergeRequestDetail; + const refs = mr.diff_refs; + if (!refs?.base_sha || !refs.head_sha || !refs.start_sha) { + throw new GitLabReviewError( + 'bad_request', + 'The merge request has no diff positions to anchor a comment to.' + ); + } + return refs; +} + +/** + * The GitLab text position for one anchor: the current diff refs plus the + * anchored path/line. RIGHT anchors the new side (`new_line`), LEFT the old + * side (`old_line`). A `startLine` range is NOT sent as an `old_line` beside + * the `new_line`: GitLab reads that pair as one changed-line relation, so a + * range on added lines (no old-side counterpart) 400s. A range anchors its + * end line on the tapped side; the range stays in the ledger key and the + * pending list, not in the provider position. + */ +function buildTextPosition( + refs: GitLabDiffRefs, + anchor: ProviderReviewInlineAnchor +): Record { + const position: Record = { + position_type: 'text', + base_sha: refs.base_sha, + start_sha: refs.start_sha, + head_sha: refs.head_sha, + new_path: anchor.path, + old_path: anchor.path, + }; + if (anchor.side === 'RIGHT') { + position.new_line = anchor.line; + } else { + position.old_line = anchor.line; + } + return position; +} + +/** + * The partial-apply reason: an inline discussion committed before a later + * rejection, so the provider already holds effects a replayed batch would + * duplicate. The retryable kind keeps the router's ledger row + * reconcile_pending instead of settling it failed. + */ +const GITLAB_INLINE_PARTIAL_APPLY_REASON = + 'GitLab applied part of this review before the request failed. Check the merge request before retrying.'; + +/** + * Classify one submitReview failure. A position outside the diff (400) is a + * clean deterministic refusal only while nothing has committed: the failed + * settle lets the client rotate its operation key and a fresh intent re-posts + * the whole batch. Once any inline discussion is live every later failure — + * mid-batch or the approval/summary step — is a partial apply, so it reports + * the retryable kind and the router reconciles instead of replaying. + */ +function classifySubmitFailure(error: unknown, inlineCommitted: boolean): GitLabReviewError { + const classified = classifyGitLabError(error); + if (inlineCommitted && !classified.retryable) { + return new GitLabReviewError('retryable', GITLAB_INLINE_PARTIAL_APPLY_REASON); + } + return classified; +} + +/** + * Create one diff discussion per anchored comment on the merge request. + * GitLab rejects a position outside the diff (400), which classifyGitLabError + * surfaces as a non-retryable bad_request through the existing taxonomy — + * but only while nothing has committed: a rejection after an earlier + * discussion committed is a partial apply, reported through the retryable + * kind so the ledger row stays reconcile_pending and never replays the + * committed comments as duplicates. + */ +async function createInlineDiscussions( + access: GitLabProjectAccess, + mrIid: number, + anchored: Array<{ anchor: ProviderReviewInlineAnchor; body: string }> +): Promise { + const refs = await fetchMrDiffRefs(access, mrIid); + let committed = false; + for (const item of anchored) { + try { + await requestGitLabJson(access, `${mrPath(access, mrIid)}/discussions`, { + method: 'POST', + body: { body: item.body, position: buildTextPosition(refs, item.anchor) }, + }); + } catch (error) { + throw committed + ? new GitLabReviewError('retryable', GITLAB_INLINE_PARTIAL_APPLY_REASON) + : error; + } + committed = true; + } +} + +/** + * Post a comment on the merge request. With an `anchor` this creates a real + * diff discussion positioned in the MR's current diff; without one it posts + * a top-level project note, byte-identical to the previous behavior. + */ +export async function addComment( + target: GitLabMrTarget & { + body: string; + anchor?: ProviderReviewInlineAnchor; + } & GitLabMutationInput +): Promise { + const access = await targetAccess(target); + try { + if (target.anchor) { + await createInlineDiscussions(access, target.mrIid, [ + { anchor: target.anchor, body: target.body }, + ]); + } else { + await createMRNote( + access.accessToken, + access.projectPath, + target.mrIid, + target.body, + access.instanceUrl + ); + } + return { done: true, replayed: false }; + } catch (error) { + throw classifyGitLabError(error); + } +} + +/** Reply inside an existing discussion thread. */ +export async function replyToDiscussion( + target: GitLabMrTarget & { + discussionId: string; + body: string; + } & GitLabMutationInput +): Promise { + const access = await targetAccess(target); + try { + await requestGitLabJson( + access, + `${mrPath(access, target.mrIid)}/discussions/${encodeURIComponent(target.discussionId)}/notes`, + { method: 'POST', body: { body: target.body } } + ); + return { done: true, replayed: false }; + } catch (error) { + throw classifyGitLabError(error); + } +} + +/** + * Submit a review. `approve` → POST /approve plus an optional summary note; + * `comment` → note; `request_changes` is not a GitLab concept and is refused + * with the exact reason — never a silent fallback to another event. An + * optional `comments` batch posts real inline diff discussions BEFORE the + * approval/summary note, so a review carries GitHub-parity inline threads; + * once any discussion has committed, every failure reports the retryable + * kind, so the router marks the ledger row reconcile_pending and a same-key + * retry never re-posts the committed comments as duplicates. + */ +export async function submitReview( + target: GitLabMrTarget & { + event: 'approve' | 'comment' | 'request_changes'; + body?: string; + comments?: ProviderReviewInlineComment[]; + } & GitLabMutationInput +): Promise { + if (target.event === 'request_changes') { + throw new GitLabReviewError('bad_request', GITLAB_REQUEST_CHANGES_UNSUPPORTED_REASON); + } + const access = await targetAccess(target); + let inlineCommitted = false; + try { + if (target.comments?.length) { + await createInlineDiscussions( + access, + target.mrIid, + target.comments.map(comment => ({ + anchor: comment, + body: comment.body, + })) + ); + inlineCommitted = true; + } + if (target.event === 'approve') { + await requestGitLabJson(access, `${mrPath(access, target.mrIid)}/approve`, { + method: 'POST', + }); + if (target.body) { + await createMRNote( + access.accessToken, + access.projectPath, + target.mrIid, + target.body, + access.instanceUrl + ); + } + } else if (target.body) { + await createMRNote( + access.accessToken, + access.projectPath, + target.mrIid, + target.body, + access.instanceUrl + ); + } else if (!target.comments?.length) { + throw new GitLabReviewError('bad_request', 'A comment review needs a body.'); + } + return { done: true, replayed: false }; + } catch (error) { + throw classifySubmitFailure(error, inlineCommitted); + } +} + +/** Fetch one discussion and read whether its resolvable note is resolved. */ +async function fetchDiscussionResolvedState( + access: GitLabProjectAccess, + mrIid: number, + discussionId: string +): Promise<{ resolved: boolean; resolvable: boolean }> { + const discussion = await requestGitLabJson( + access, + `${mrPath(access, mrIid)}/discussions/${encodeURIComponent(discussionId)}` + ); + const resolvableNote = discussion?.notes?.find(note => note.resolvable); + return { + resolvable: Boolean(resolvableNote), + resolved: resolvableNote?.resolved === true, + }; +} + +async function setThreadResolved( + target: GitLabMrTarget & { discussionId: string } & GitLabMutationInput, + resolved: boolean +): Promise { + const access = await targetAccess(target); + try { + const state = await fetchDiscussionResolvedState(access, target.mrIid, target.discussionId); + if (!state.resolvable) { + throw new GitLabReviewError('bad_request', 'This discussion cannot be resolved on GitLab.'); + } + if (state.resolved === resolved) { + return { done: true, replayed: true }; + } + await requestGitLabJson( + access, + `${mrPath(access, target.mrIid)}/discussions/${encodeURIComponent(target.discussionId)}`, + { method: 'PUT', query: { resolved } } + ); + return { done: true, replayed: false }; + } catch (error) { + throw classifyGitLabError(error); + } +} + +/** Resolve a discussion thread (PUT discussions). */ +export async function resolveThread( + target: GitLabMrTarget & { discussionId: string } & GitLabMutationInput +): Promise { + return setThreadResolved(target, true); +} + +/** Un-resolve a discussion thread (PUT discussions). */ +export async function unresolveThread( + target: GitLabMrTarget & { discussionId: string } & GitLabMutationInput +): Promise { + return setThreadResolved(target, false); +} + +/** + * Re-fetch the MR and compare the current head against the caller's fence. + * A moved head is refused BEFORE any merge call, so a stale revision can + * never merge another commit or be redirected (requirement 16). + */ +function requireHeadShaFence(mr: GitLabMergeRequestDetail, expectedHeadSha: string): void { + const currentHead = mr.diff_refs?.head_sha || mr.sha; + if (currentHead !== expectedHeadSha) { + throw new GitLabReviewError('stale_head', GITLAB_STALE_HEAD_REASON); + } +} + +/** + * Merge the MR. The caller's `expectedHeadSha` is re-verified against a fresh + * fetch and passed to GitLab as `sha`, so the merge can only land the exact + * revision the reviewer saw. + */ +export async function mergePullRequest( + target: GitLabMrTarget & { + expectedHeadSha: string; + squash?: boolean; + shouldRemoveSourceBranch?: boolean; + commitTitle?: string; + commitMessage?: string; + } & GitLabMutationInput +): Promise { + const access = await targetAccess(target); + try { + const mr = (await fetchGitLabMergeRequest({ + accessToken: access.accessToken, + projectId: access.projectPath, + mrIid: target.mrIid, + instanceUrl: access.instanceUrl, + })) as GitLabMergeRequestDetail; + if (mr.state === 'merged') { + // The target state already holds: report the replay, run no effect. + return { done: true, replayed: true }; + } + requireHeadShaFence(mr, target.expectedHeadSha); + if (mr.state === 'closed' || mr.state === 'locked') { + throw new GitLabReviewError('bad_request', 'The merge request is closed.'); + } + await requestGitLabJson(access, `${mrPath(access, target.mrIid)}/merge`, { + method: 'PUT', + body: { + sha: target.expectedHeadSha, + ...(target.squash !== undefined ? { squash: target.squash } : {}), + ...(target.shouldRemoveSourceBranch !== undefined + ? { should_remove_source_branch: target.shouldRemoveSourceBranch } + : {}), + ...(target.commitTitle ? { merge_commit_title: target.commitTitle } : {}), + ...(target.commitMessage ? { merge_commit_message: target.commitMessage } : {}), + }, + }); + return { done: true, replayed: false }; + } catch (error) { + throw classifyGitLabError(error); + } +} + +/** + * Enable merge-when-pipeline-succeeds (GitLab's auto-merge) through the merge + * endpoint: the plain merge-request update endpoint does not accept the + * attribute, so a PUT there would succeed without arming auto-merge. + * `expectedHeadSha` is REQUIRED and is sent as `sha` on the merge call, so a + * moved head can never arm auto-merge on another revision, and arming is + * refused while the MR has no active pipeline — GitLab would merge + * immediately in that state. Already-armed reports `replayed`. + */ +export async function enableAutoMerge( + target: GitLabMrTarget & { expectedHeadSha: string } & GitLabMutationInput +): Promise { + const access = await targetAccess(target); + try { + const mr = (await fetchGitLabMergeRequest({ + accessToken: access.accessToken, + projectId: access.projectPath, + mrIid: target.mrIid, + instanceUrl: access.instanceUrl, + })) as GitLabMergeRequestDetail; + // The head fence guards the replay too: an auto-merge armed on another + // revision must be reported as stale, never as "already holds". + requireHeadShaFence(mr, target.expectedHeadSha); + if (mr.merge_when_pipeline_succeeds === true) { + return { done: true, replayed: true }; + } + if (!hasActivePipeline(mr)) { + throw new GitLabReviewError('bad_request', GITLAB_AUTO_MERGE_NO_PIPELINE_REASON); + } + await requestGitLabJson(access, `${mrPath(access, target.mrIid)}/merge`, { + method: 'PUT', + body: { merge_when_pipeline_succeeds: true, sha: target.expectedHeadSha }, + }); + return { done: true, replayed: false }; + } catch (error) { + throw classifyGitLabError(error); + } +} + +/** + * Disable merge-when-pipeline-succeeds through the dedicated cancel endpoint: + * the plain merge-request update endpoint does not accept the attribute, so + * a PUT with `false` there would succeed without disarming auto-merge. + * Already-disabled reports `replayed`; an optional head fence refuses a stale + * revision. Cancelling arms nothing, so unlike enableAutoMerge the fence is + * not required here. + */ +export async function disableAutoMerge( + target: GitLabMrTarget & { expectedHeadSha?: string } & GitLabMutationInput +): Promise { + const access = await targetAccess(target); + try { + const mr = (await fetchGitLabMergeRequest({ + accessToken: access.accessToken, + projectId: access.projectPath, + mrIid: target.mrIid, + instanceUrl: access.instanceUrl, + })) as GitLabMergeRequestDetail; + if (target.expectedHeadSha) { + requireHeadShaFence(mr, target.expectedHeadSha); + } + if (mr.merge_when_pipeline_succeeds !== true) { + return { done: true, replayed: true }; + } + await requestGitLabJson( + access, + `${mrPath(access, target.mrIid)}/cancel_merge_when_pipeline_succeeds`, + { method: 'POST' } + ); + return { done: true, replayed: false }; + } catch (error) { + throw classifyGitLabError(error); + } +} + +/** + * Delete a project branch. A branch GitLab no longer reports is the target + * state already, so it reports `replayed` rather than an error. + */ +export async function deleteBranch( + target: GitLabMrTarget & { branchName: string } & GitLabMutationInput +): Promise { + const access = await targetAccess(target); + try { + await requestGitLabJson( + access, + `/api/v4/projects/${encodeURIComponent(access.projectPath)}/repository/branches/${encodeURIComponent(target.branchName)}`, + { method: 'DELETE' } + ); + return { done: true, replayed: false }; + } catch (error) { + if (error instanceof GitLabReviewError && error.kind === 'not_found') { + return { done: true, replayed: true }; + } + throw error; + } +} diff --git a/apps/web/src/routers/cloud-agent-next-router.branches.test.ts b/apps/web/src/routers/cloud-agent-next-router.branches.test.ts new file mode 100644 index 0000000000..5f820722a8 --- /dev/null +++ b/apps/web/src/routers/cloud-agent-next-router.branches.test.ts @@ -0,0 +1,259 @@ +/** + * @jest-environment node + */ +import { describe, expect, it, beforeAll, beforeEach } from '@jest/globals'; +// @swc/jest only hoists `jest.mock` calls when `jest` is the GLOBAL binding +// (@types/jest). Importing `jest` from '@jest/globals' defeats hoisting: the +// mocked modules load for real before registration. Same pattern as +// github-pr-review-router.test.ts. +import { createCallerFactory } from '@/lib/trpc/init'; +import type { User } from '@kilocode/db/schema'; +import { BITBUCKET_ORGANIZATION_ONLY_MESSAGE } from '@/lib/provider-review/bitbucket-authorization'; +import { cloudAgentNextRouter } from './cloud-agent-next-router'; + +const USER_ID = 'user-1'; + +// ----- mocked seams (all factories delegate lazily) ---------------------------- + +// The cloud-agent router's heavy runtime deps, mocked exactly as +// cloud-agent-next-router.test.ts does so the router module loads without +// network, PostHog, or R2 clients. +jest.mock('@/lib/tokens', () => ({ + generateCloudAgentToken: jest.fn(() => 'cloud-agent-token'), + generateInternalServiceToken: jest.fn(), + TOKEN_EXPIRY: 60, +})); +jest.mock('@/lib/cloud-agent-next/cloud-agent-client', () => ({ + createCloudAgentNextClient: jest.fn(), + createCloudAgentNextClientForModel: jest.fn(), + rethrowAsPaymentRequired: jest.fn(), +})); +jest.mock('@/lib/cloud-agent-next/worktree-chat', () => ({ createWorktreeChat: jest.fn() })); +jest.mock('@/lib/trpc/min-version', () => ({ + ...jest.requireActual('@/lib/trpc/min-version'), + getMinimumVersions: jest.fn(async () => ({ ios: '0.0.0', android: '0.0.0' })), + enforceMinimumVersion: jest.fn(() => ({ pass: true })), +})); +jest.mock('@/lib/cloud-agent-next/balance-check-eligibility', () => ({ + computeCloudAgentNextBalanceCheckEligibility: jest.fn(), +})); +jest.mock('@/lib/posthog-feature-flags', () => ({ + isFeatureFlagEnabledOrDevelopment: jest.fn(async () => false), +})); +jest.mock('@/lib/user/balance', () => ({ getBalanceForUser: jest.fn() })); +jest.mock('@/lib/cloud-agent/github-integration-helpers', () => ({ + fetchGitHubRepositoriesForUser: jest.fn(), +})); +jest.mock('@/lib/cloud-agent/gitlab-integration-helpers', () => ({ + buildGitLabCloneUrl: jest.fn(), + fetchGitLabRepositoriesForUser: jest.fn(), + getGitLabInstanceUrlForUser: jest.fn(), +})); +jest.mock('@/lib/cloud-agent/order-repositories', () => ({ + orderRepositoriesByUsage: jest.fn(async ({ repositories }: any) => repositories), +})); +jest.mock('@/lib/r2/cloud-agent-attachments', () => ({ + generateImageUploadUrl: jest.fn(), + generateCloudAgentAttachmentUploadUrl: jest.fn(), + generateCloudAgentAttachmentDownloadUrl: jest.fn(), +})); +jest.mock('@/lib/cloud-agent/session-ownership', () => ({ + verifyUserOwnsSessionV2ByCloudAgentId: jest.fn(), +})); + +// The branch listing's provider seams: the integration lookup, the GitHub +// and GitLab branch services, and the Bitbucket authorization/read layer. +const mockGetIntegrationForOwner = jest.fn(); +jest.mock('@/lib/integrations/db/platform-integrations', () => ({ + getIntegrationForOwner: (...a: unknown[]) => mockGetIntegrationForOwner(...a), +})); + +const mockListBranches = jest.fn(); +jest.mock('@/lib/integrations/github-apps-service', () => ({ + listBranches: (...a: unknown[]) => mockListBranches(...a), +})); + +const mockGetValidGitLabToken = jest.fn(); +jest.mock('@/lib/integrations/gitlab-service', () => ({ + getValidGitLabToken: (...a: unknown[]) => mockGetValidGitLabToken(...a), +})); + +const mockFetchGitLabBranches = jest.fn(); +jest.mock('@/lib/integrations/platforms/gitlab/adapter', () => ({ + ...jest.requireActual('@/lib/integrations/platforms/gitlab/adapter'), + fetchGitLabBranches: (...a: unknown[]) => mockFetchGitLabBranches(...a), +})); + +const mockAuthorizeRepository = jest.fn(); +jest.mock('@/lib/provider-review/bitbucket-authorization', () => ({ + ...jest.requireActual('@/lib/provider-review/bitbucket-authorization'), + authorizeRepository: (...a: unknown[]) => mockAuthorizeRepository(...a), +})); + +const mockFetchPage = jest.fn(); +const mockRequestBitbucketJson = jest.fn(); +jest.mock('@/lib/provider-review/bitbucket-read', () => ({ + ...jest.requireActual('@/lib/provider-review/bitbucket-read'), + fetchPage: (...a: unknown[]) => mockFetchPage(...a), + requestBitbucketJson: (...a: unknown[]) => mockRequestBitbucketJson(...a), +})); + +// ----- fixtures --------------------------------------------------------------- + +const activeIntegration = { id: 'int-1', integration_status: 'active' }; + +/** An active GitLab integration whose repository cache lists the project. */ +const gitlabIntegration = { + id: 'int-1', + integration_status: 'active', + metadata: { gitlab_instance_url: 'https://gitlab.example.com' }, + repositories: [{ id: 7, name: 'proj', full_name: 'group/sub/proj', private: true }], +}; + +let caller: any; + +beforeAll(() => { + caller = createCallerFactory(cloudAgentNextRouter)({ + user: { id: USER_ID, is_admin: false } as User, + }); +}); + +beforeEach(() => { + jest.clearAllMocks(); + mockGetIntegrationForOwner.mockResolvedValue(activeIntegration); + mockListBranches.mockResolvedValue({ + branches: [ + { name: 'main', isDefault: true }, + { name: 'feature/x', isDefault: false }, + ], + }); + mockGetValidGitLabToken.mockResolvedValue('glpat-mock-token'); + mockFetchGitLabBranches.mockResolvedValue([ + { name: 'dev', default: true, protected: true }, + { name: 'release', default: false, protected: false }, + ]); +}); + +describe('cloudAgentNextRouter.listRepositoryBranches (personal)', () => { + it('lists GitHub branches against the USER-owned integration the server resolved', async () => { + const result = await caller.listRepositoryBranches({ + platform: 'github', + repository: { fullName: 'octocat/hello' }, + }); + expect(result).toEqual({ defaultBranch: 'main', branches: ['main', 'feature/x'] }); + expect(mockGetIntegrationForOwner).toHaveBeenCalledWith( + { type: 'user', id: USER_ID }, + 'github' + ); + expect(mockListBranches).toHaveBeenCalledWith( + { type: 'user', id: USER_ID }, + 'int-1', + 'octocat/hello' + ); + }); + + it('lists GitLab branches from the repository-cache-authorized project', async () => { + mockGetIntegrationForOwner.mockResolvedValue(gitlabIntegration); + const result = await caller.listRepositoryBranches({ + platform: 'gitlab', + repository: { fullName: 'group/sub/proj' }, + }); + expect(result).toEqual({ defaultBranch: 'dev', branches: ['dev', 'release'] }); + expect(mockGetIntegrationForOwner).toHaveBeenCalledWith( + { type: 'user', id: USER_ID }, + 'gitlab' + ); + // The token and instance are server-derived; the project is the cache + // match, never the caller's raw path. + expect(mockGetValidGitLabToken).toHaveBeenCalledWith(gitlabIntegration, { userId: USER_ID }); + expect(mockFetchGitLabBranches).toHaveBeenCalledWith( + 'glpat-mock-token', + 'group/sub/proj', + 'https://gitlab.example.com' + ); + }); + + it('refuses a GitLab project outside the connected repositories', async () => { + mockGetIntegrationForOwner.mockResolvedValue(gitlabIntegration); + await expect( + caller.listRepositoryBranches({ + platform: 'gitlab', + repository: { fullName: 'other/project' }, + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + expect(mockGetValidGitLabToken).not.toHaveBeenCalled(); + expect(mockFetchGitLabBranches).not.toHaveBeenCalled(); + }); + + it('reports Bitbucket as organization-only — an explicit refusal, never an empty success', async () => { + await expect( + caller.listRepositoryBranches({ + platform: 'bitbucket', + repository: { fullName: 'acme/widgets' }, + }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + message: BITBUCKET_ORGANIZATION_ONLY_MESSAGE, + }); + expect(mockAuthorizeRepository).not.toHaveBeenCalled(); + expect(mockGetIntegrationForOwner).not.toHaveBeenCalled(); + expect(mockFetchPage).not.toHaveBeenCalled(); + }); + + it('refuses a missing or inactive integration with a clear NOT_FOUND', async () => { + mockGetIntegrationForOwner.mockResolvedValueOnce(null); + await expect( + caller.listRepositoryBranches({ + platform: 'github', + repository: { fullName: 'octocat/hello' }, + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND', message: expect.stringContaining('GitHub') }); + expect(mockListBranches).not.toHaveBeenCalled(); + + mockGetIntegrationForOwner.mockResolvedValueOnce({ + id: 'int-9', + integration_status: 'revoked', + }); + await expect( + caller.listRepositoryBranches({ + platform: 'gitlab', + repository: { fullName: 'group/proj' }, + }) + ).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: expect.stringContaining('no longer active'), + }); + expect(mockFetchGitLabBranches).not.toHaveBeenCalled(); + }); + + it('accepts no integration id, token, organizationId, or host from the client', async () => { + for (const smuggled of [ + { integrationId: 'int-1' }, + { token: 'ghp_secret' }, + { organizationId: '2b1d4c8e-9f3a-4e5d-8c7b-6a5948372615' }, + { host: 'https://evil.example' }, + ]) { + await expect( + caller.listRepositoryBranches({ + platform: 'github', + repository: { fullName: 'octocat/hello' }, + ...smuggled, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + } + // The repository must be the nested { fullName } shape. + await expect( + caller.listRepositoryBranches({ platform: 'github', fullName: 'octocat/hello' }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(mockGetIntegrationForOwner).not.toHaveBeenCalled(); + }); + + it('rejects a malformed repository full name', async () => { + for (const fullName of ['noseparator', '', 'trailing/', 'spaces not/allowed']) { + await expect( + caller.listRepositoryBranches({ platform: 'github', repository: { fullName } }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + } + expect(mockListBranches).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/routers/cloud-agent-next-router.ts b/apps/web/src/routers/cloud-agent-next-router.ts index 5ef263fcd3..2b6001976c 100644 --- a/apps/web/src/routers/cloud-agent-next-router.ts +++ b/apps/web/src/routers/cloud-agent-next-router.ts @@ -17,6 +17,11 @@ import { fetchGitLabRepositoriesForUser, } from '@/lib/cloud-agent/gitlab-integration-helpers'; import { orderRepositoriesByUsage } from '@/lib/cloud-agent/order-repositories'; +import { + listProviderRepositoryBranches, + ProviderBranchListingSchema, + repositoryFullNameSchema, +} from '@/lib/cloud-agent/provider-branch-listing'; import { personalPrepareSessionNextSchema, basePrepareSessionNextOutputSchema, @@ -683,4 +688,30 @@ export const cloudAgentNextRouter = createTRPCRouter({ errorMessage: result.errorMessage, }; }), + + /** + * List the branches of one repository for the new-session flow (personal + * context). GitHub and GitLab run against the user's own connection; the + * integration and credentials are resolved server-side, never supplied + * here. A Bitbucket call returns the explicit org-only unavailable state + * (FORBIDDEN) — never an empty success. `organizationId` is not an + * accepted field: the org endpoint owns that context. + */ + listRepositoryBranches: baseProcedure + .input( + z + .object({ + platform: z.enum(['github', 'gitlab', 'bitbucket']), + repository: z.object({ fullName: repositoryFullNameSchema }).strict(), + }) + .strict() + ) + .output(ProviderBranchListingSchema) + .query(async ({ ctx, input }) => + listProviderRepositoryBranches({ + platform: input.platform, + userId: ctx.user.id, + repositoryFullName: input.repository.fullName, + }) + ), }); diff --git a/apps/web/src/routers/github-pr-review-router.ts b/apps/web/src/routers/github-pr-review-router.ts index a061ce89cd..bbb29b896f 100644 --- a/apps/web/src/routers/github-pr-review-router.ts +++ b/apps/web/src/routers/github-pr-review-router.ts @@ -1002,7 +1002,7 @@ type ReplayedResult = T & { replayed: true }; * creates a ledger row. Throws PRECONDITION_FAILED `terms_required` when * absent. */ -async function assertTermsAccepted(userId: string): Promise { +export async function assertTermsAccepted(userId: string): Promise { const [row] = await db .select({ id: user_terms_acceptances.id }) .from(user_terms_acceptances) diff --git a/apps/web/src/routers/organizations/organization-cloud-agent-next-router.branches.test.ts b/apps/web/src/routers/organizations/organization-cloud-agent-next-router.branches.test.ts new file mode 100644 index 0000000000..3b475d2b7c --- /dev/null +++ b/apps/web/src/routers/organizations/organization-cloud-agent-next-router.branches.test.ts @@ -0,0 +1,496 @@ +/** + * @jest-environment node + */ +import { describe, expect, it, beforeAll, beforeEach } from '@jest/globals'; +// @swc/jest only hoists `jest.mock` calls when `jest` is the GLOBAL binding +// (@types/jest). Importing `jest` from '@jest/globals' defeats hoisting: the +// mocked modules load for real before registration. Same pattern as +// github-pr-review-router.test.ts. +import type * as TrpcInitModule from '@/lib/trpc/init'; +import type * as OrganizationUtilsModule from '@/routers/organizations/utils'; +import type * as ZodModule from 'zod'; +import { createCallerFactory } from '@/lib/trpc/init'; +import type { User } from '@kilocode/db/schema'; +import { BitbucketReviewError } from '@/lib/provider-review/bitbucket-authorization'; +import { organizationCloudAgentNextRouter } from './organization-cloud-agent-next-router'; + +const ORG_ID = '9a283301-b75d-4375-a1ba-e319a02e18b7'; +const USER_ID = 'user-1'; + +// ----- mocked seams (all factories delegate lazily) ---------------------------- + +// The global `jest` binding comes from @types/jest, whose `fn` takes either no +// type arguments or the (return, args) pair — not the single function type that +// `@jest/globals`' `jest.fn` accepts. We cannot import `jest` here without +// breaking @swc/jest hoisting, so spell the pair out. +const mockEnsureOrganizationAccess = jest.fn< + ReturnType, + Parameters +>(); + +jest.mock('@/routers/organizations/utils', () => { + const trpcInit = jest.requireActual('@/lib/trpc/init'); + const zod = jest.requireActual('zod'); + const organizationProcedure = trpcInit.baseProcedure + .input(zod.object({ organizationId: zod.uuid() })) + .use(async ({ ctx, input, next }: any) => { + await mockEnsureOrganizationAccess(ctx, input.organizationId); + return next(); + }); + return { + ...jest.requireActual('@/routers/organizations/utils'), + // Lazy delegation: the factory runs while the router module is being + // required (during the hoisted-import phase), before the const above is + // initialized. Reading it eagerly throws a TDZ ReferenceError. + ensureOrganizationAccess: (...args: unknown[]) => + mockEnsureOrganizationAccess(...(args as Parameters)), + organizationMemberProcedure: organizationProcedure, + organizationMemberMutationProcedure: organizationProcedure, + }; +}); + +// The org router's heavy runtime deps, mocked exactly as the existing +// organization-cloud-agent-next-router.test.ts does. +jest.mock('@/lib/tokens', () => ({ + generateCloudAgentToken: jest.fn(() => 'cloud-agent-token'), + generateInternalServiceToken: jest.fn(), + TOKEN_EXPIRY: 60, +})); +jest.mock('@/lib/cloud-agent-next/cloud-agent-client', () => ({ + createCloudAgentNextClient: jest.fn(), + createCloudAgentNextClientForModel: jest.fn(), + rethrowAsPaymentRequired: jest.fn(), +})); +jest.mock('@/lib/cloud-agent-next/worktree-chat', () => ({ createWorktreeChat: jest.fn() })); +jest.mock('@/lib/trpc/min-version', () => ({ + ...jest.requireActual('@/lib/trpc/min-version'), + getMinimumVersions: jest.fn(async () => ({ ios: '0.0.0', android: '0.0.0' })), + enforceMinimumVersion: jest.fn(() => ({ pass: true })), +})); +jest.mock('@/lib/cloud-agent-next/balance-check-eligibility', () => ({ + computeCloudAgentNextBalanceCheckEligibility: jest.fn(), +})); +jest.mock('@/lib/posthog-feature-flags', () => ({ + isFeatureFlagEnabledOrDevelopment: jest.fn(async () => false), +})); +jest.mock('@/lib/organizations/organization-usage', () => ({ + getBalanceForOrganizationUser: jest.fn(), +})); +jest.mock('@/lib/cloud-agent/bitbucket-integration-helpers', () => ({ + ...jest.requireActual('@/lib/cloud-agent/bitbucket-integration-helpers'), + fetchBitbucketRepositoriesForOrganization: jest.fn(), +})); +jest.mock('@/lib/cloud-agent/github-integration-helpers', () => ({ + fetchGitHubRepositoriesForOrganization: jest.fn(), + fetchAllGitHubRepositoriesForOrganization: jest.fn(), +})); +jest.mock('@/lib/cloud-agent/gitlab-integration-helpers', () => ({ + buildGitLabCloneUrl: jest.fn(), + fetchGitLabRepositoriesForOrganization: jest.fn(), + getGitLabInstanceUrlForOrganization: jest.fn(), +})); +jest.mock('@/lib/cloud-agent/order-repositories', () => ({ + orderRepositoriesByUsage: jest.fn(async ({ repositories }: any) => repositories), +})); +jest.mock('@/lib/cloud-agent/session-ownership', () => ({ + verifyOrgOwnsSessionV2ByCloudAgentId: jest.fn(), +})); +jest.mock('@/lib/r2/cloud-agent-attachments', () => ({ + generateImageUploadUrl: jest.fn(), + generateCloudAgentAttachmentUploadUrl: jest.fn(), +})); + +// The branch listing's provider seams. +const mockGetIntegrationForOwner = jest.fn(); +const mockGetIntegrationsByOrganization = jest.fn(); +jest.mock('@/lib/integrations/db/platform-integrations', () => ({ + getIntegrationForOwner: (...a: unknown[]) => mockGetIntegrationForOwner(...a), + getIntegrationsByOrganization: (...a: unknown[]) => mockGetIntegrationsByOrganization(...a), +})); + +const mockListBranches = jest.fn(); +jest.mock('@/lib/integrations/github-apps-service', () => ({ + listBranches: (...a: unknown[]) => mockListBranches(...a), +})); + +const mockGetValidGitLabToken = jest.fn(); +jest.mock('@/lib/integrations/gitlab-service', () => ({ + getValidGitLabToken: (...a: unknown[]) => mockGetValidGitLabToken(...a), +})); + +const mockFetchGitLabBranches = jest.fn(); +jest.mock('@/lib/integrations/platforms/gitlab/adapter', () => ({ + ...jest.requireActual('@/lib/integrations/platforms/gitlab/adapter'), + fetchGitLabBranches: (...a: unknown[]) => mockFetchGitLabBranches(...a), +})); + +const mockAuthorizeRepository = jest.fn(); +jest.mock('@/lib/provider-review/bitbucket-authorization', () => ({ + ...jest.requireActual('@/lib/provider-review/bitbucket-authorization'), + authorizeRepository: (...a: unknown[]) => mockAuthorizeRepository(...a), +})); + +const mockFetchPage = jest.fn(); +const mockRequestBitbucketJson = jest.fn(); +jest.mock('@/lib/provider-review/bitbucket-read', () => ({ + ...jest.requireActual('@/lib/provider-review/bitbucket-read'), + fetchPage: (...a: unknown[]) => mockFetchPage(...a), + requestBitbucketJson: (...a: unknown[]) => mockRequestBitbucketJson(...a), +})); + +// ----- fixtures --------------------------------------------------------------- + +const activeIntegration = { id: 'int-1', integration_status: 'active' }; + +/** An active organization GitLab integration whose cache lists the project. */ +const gitlabIntegration = { + id: 'int-1', + integration_status: 'active', + metadata: { gitlab_instance_url: 'https://gitlab.example.com' }, + repositories: [{ id: 7, name: 'proj', full_name: 'group/proj', private: true }], +}; + +/** A healthy organization-owned GitHub installation row with its repository cache. */ +const githubInstallation = (id: string, repositoryFullNames: string[]) => ({ + id, + integration_status: 'active', + suspended_at: null, + auth_invalid_at: null, + repositories: repositoryFullNames.map((full_name, index) => ({ id: index + 1, full_name })), +}); + +/** The refusal GitHub returns for a repository an installation cannot see. */ +const notVisible = () => Object.assign(new Error('Not Found'), { status: 404 }); + +/** The access the authorization layer returns — server-derived identity. */ +const bitbucketAccess = { + accessToken: 'workspace-token', + workspace: { uuid: '{ws-uuid}', slug: 'Acme' }, + repository: { uuid: '{repo-uuid}', slug: 'Widgets', fullName: 'Acme/Widgets' }, + owner: { type: 'organization', organizationId: ORG_ID, userId: USER_ID }, +}; + +let caller: any; + +beforeAll(() => { + caller = createCallerFactory(organizationCloudAgentNextRouter)({ + user: { id: USER_ID, is_admin: false } as User, + }); +}); + +beforeEach(() => { + jest.clearAllMocks(); + // Reset the bitbucket queue mocks so no leftover once-implementations can + // leak between tests, then define the default two-page walk. + mockFetchPage.mockReset(); + mockRequestBitbucketJson.mockReset(); + mockEnsureOrganizationAccess.mockResolvedValue('member'); + mockGetIntegrationForOwner.mockResolvedValue(activeIntegration); + mockGetIntegrationsByOrganization.mockResolvedValue([ + githubInstallation('int-1', ['octocat/hello']), + ]); + mockListBranches.mockResolvedValue({ + branches: [ + { name: 'main', isDefault: true }, + { name: 'feature/x', isDefault: false }, + ], + }); + mockGetValidGitLabToken.mockResolvedValue('glpat-mock-token'); + mockFetchGitLabBranches.mockResolvedValue([ + { name: 'dev', default: true, protected: true }, + { name: 'release', default: false, protected: false }, + ]); + mockAuthorizeRepository.mockResolvedValue(bitbucketAccess); + // The real `GET /2.0/repositories/{workspace}/{slug}` payload: the default + // branch is `mainbranch.name` — the same field every other Bitbucket + // adapter in this codebase reads (bitbucket-api.ts, workspace-access-token- + // adapter.ts). Extra provider fields must not break the parse. + mockRequestBitbucketJson.mockResolvedValue({ + uuid: '{repo-uuid}', + full_name: 'Acme/Widgets', + mainbranch: { name: 'master' }, + branching_model: { development: { name: 'dev' }, production: { name: 'master' } }, + }); + mockFetchPage.mockImplementation( + async (_access: unknown, _path: unknown, _id: unknown, cursor: unknown) => { + if (cursor === undefined) { + return { + values: [ + { name: 'master', type: 'branch' }, + { name: 'feat', type: 'branch' }, + ], + nextCursor: 'page-2', + }; + } + return { + values: [ + { name: 'master', type: 'branch' }, + { name: 'release', type: 'branch' }, + ], + nextCursor: null, + }; + } + ); +}); + +describe('organizationCloudAgentNextRouter.listRepositoryBranches', () => { + it('runs the organization guard before any provider call', async () => { + mockEnsureOrganizationAccess.mockRejectedValueOnce( + new Error('You do not have access to this organization') + ); + await expect( + caller.listRepositoryBranches({ + organizationId: ORG_ID, + platform: 'github', + repository: { fullName: 'octocat/hello' }, + }) + ).rejects.toBeDefined(); + expect(mockGetIntegrationForOwner).not.toHaveBeenCalled(); + expect(mockGetIntegrationsByOrganization).not.toHaveBeenCalled(); + expect(mockListBranches).not.toHaveBeenCalled(); + }); + + it('lists GitHub branches against the ORG-owned integration the server resolved', async () => { + const result = await caller.listRepositoryBranches({ + organizationId: ORG_ID, + platform: 'github', + repository: { fullName: 'octocat/hello' }, + }); + expect(result).toEqual({ defaultBranch: 'main', branches: ['main', 'feature/x'] }); + expect(mockGetIntegrationsByOrganization).toHaveBeenCalledWith(ORG_ID, 'github'); + expect(mockListBranches).toHaveBeenCalledWith( + { type: 'org', id: ORG_ID }, + 'int-1', + 'octocat/hello' + ); + }); + + it('resolves the installation that owns the repository, not the primary row', async () => { + // Two connected GitHub accounts: the primary (oldest) installation cannot + // see the repository, the second one caches it. + mockGetIntegrationsByOrganization.mockResolvedValue([ + githubInstallation('int-primary', ['other/repo']), + githubInstallation('int-owning', ['Octocat/Hello']), + ]); + const result = await caller.listRepositoryBranches({ + organizationId: ORG_ID, + platform: 'github', + repository: { fullName: 'octocat/hello' }, + }); + expect(result).toEqual({ defaultBranch: 'main', branches: ['main', 'feature/x'] }); + expect(mockListBranches).toHaveBeenCalledTimes(1); + expect(mockListBranches).toHaveBeenCalledWith( + { type: 'org', id: ORG_ID }, + 'int-owning', + 'octocat/hello' + ); + }); + + it('skips unhealthy installations when resolving the repository', async () => { + mockGetIntegrationsByOrganization.mockResolvedValue([ + { ...githubInstallation('int-suspended', ['octocat/hello']), suspended_at: 'ts' }, + githubInstallation('int-healthy', ['octocat/hello']), + ]); + await caller.listRepositoryBranches({ + organizationId: ORG_ID, + platform: 'github', + repository: { fullName: 'octocat/hello' }, + }); + expect(mockListBranches).toHaveBeenCalledTimes(1); + expect(mockListBranches.mock.calls[0][1]).toBe('int-healthy'); + }); + + it('falls back to the other healthy installations when every repository cache is stale', async () => { + mockGetIntegrationsByOrganization.mockResolvedValue([ + githubInstallation('int-primary', []), + githubInstallation('int-second', []), + ]); + mockListBranches.mockRejectedValueOnce(notVisible()); + const result = await caller.listRepositoryBranches({ + organizationId: ORG_ID, + platform: 'github', + repository: { fullName: 'octocat/hello' }, + }); + expect(result).toEqual({ defaultBranch: 'main', branches: ['main', 'feature/x'] }); + expect(mockListBranches.mock.calls.map(call => call[1])).toEqual(['int-primary', 'int-second']); + }); + + it('refuses with a clear NOT_FOUND when no installation can see the repository', async () => { + mockGetIntegrationsByOrganization.mockResolvedValue([ + githubInstallation('int-primary', []), + githubInstallation('int-second', []), + ]); + mockListBranches.mockRejectedValue(notVisible()); + await expect( + caller.listRepositoryBranches({ + organizationId: ORG_ID, + platform: 'github', + repository: { fullName: 'octocat/hello' }, + }) + ).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: expect.stringContaining('not available in any connected GitHub installation'), + }); + expect(mockListBranches).toHaveBeenCalledTimes(2); + }); + + it('surfaces a non-visibility GitHub failure instead of retrying other installations', async () => { + mockGetIntegrationsByOrganization.mockResolvedValue([ + githubInstallation('int-primary', ['octocat/hello']), + githubInstallation('int-second', ['octocat/hello']), + ]); + mockListBranches.mockRejectedValueOnce(Object.assign(new Error('boom'), { status: 500 })); + await expect( + caller.listRepositoryBranches({ + organizationId: ORG_ID, + platform: 'github', + repository: { fullName: 'octocat/hello' }, + }) + ).rejects.toBeDefined(); + expect(mockListBranches).toHaveBeenCalledTimes(1); + }); + + it('refuses when the organization has no healthy GitHub installation', async () => { + mockGetIntegrationsByOrganization.mockResolvedValueOnce([]); + await expect( + caller.listRepositoryBranches({ + organizationId: ORG_ID, + platform: 'github', + repository: { fullName: 'octocat/hello' }, + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND', message: expect.stringContaining('No GitHub') }); + + mockGetIntegrationsByOrganization.mockResolvedValueOnce([ + { ...githubInstallation('int-revoked', ['octocat/hello']), integration_status: 'revoked' }, + ]); + await expect( + caller.listRepositoryBranches({ + organizationId: ORG_ID, + platform: 'github', + repository: { fullName: 'octocat/hello' }, + }) + ).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: expect.stringContaining('no longer active'), + }); + expect(mockListBranches).not.toHaveBeenCalled(); + }); + + it('lists GitLab branches with the acting user as the credential actor', async () => { + mockGetIntegrationForOwner.mockResolvedValue(gitlabIntegration); + const result = await caller.listRepositoryBranches({ + organizationId: ORG_ID, + platform: 'gitlab', + repository: { fullName: 'group/proj' }, + }); + expect(result).toEqual({ defaultBranch: 'dev', branches: ['dev', 'release'] }); + // The cache match authorizes the project; the token releases for the + // acting user inside the organization. + expect(mockGetValidGitLabToken).toHaveBeenCalledWith(gitlabIntegration, { + userId: USER_ID, + organizationId: ORG_ID, + }); + expect(mockFetchGitLabBranches).toHaveBeenCalledWith( + 'glpat-mock-token', + 'group/proj', + 'https://gitlab.example.com' + ); + }); + + it('refuses an organization GitLab project outside the connected repositories', async () => { + mockGetIntegrationForOwner.mockResolvedValue(gitlabIntegration); + await expect( + caller.listRepositoryBranches({ + organizationId: ORG_ID, + platform: 'gitlab', + repository: { fullName: 'other/project' }, + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + expect(mockGetValidGitLabToken).not.toHaveBeenCalled(); + expect(mockFetchGitLabBranches).not.toHaveBeenCalled(); + }); + + it('lists Bitbucket branches with the server-derived workspace identity and follows pagination inside it', async () => { + const result = await caller.listRepositoryBranches({ + organizationId: ORG_ID, + platform: 'bitbucket', + repository: { fullName: 'acme/widgets' }, + }); + expect(mockAuthorizeRepository).toHaveBeenCalledWith( + { type: 'organization', organizationId: ORG_ID, userId: USER_ID }, + 'acme', + 'widgets' + ); + // The repository-metadata default and the paged refs both address the + // SERVER-DERIVED identity ('Acme'/'Widgets'), never the client's casing: + // a page cursor can only ever walk the authorized repository's own + // refs/branches path. The default branch comes from the repository + // object's `mainbranch` — Bitbucket Cloud has no `/branch-model` + // endpoint, so the listing must not invent one. + expect(mockRequestBitbucketJson).toHaveBeenCalledTimes(1); + expect(mockRequestBitbucketJson).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: 'workspace-token' }), + '/2.0/repositories/Acme/Widgets' + ); + expect(mockFetchPage).toHaveBeenCalledTimes(2); + const [accessArg, basePath, identity, cursor, guard] = mockFetchPage.mock.calls[0]; + expect(accessArg).toEqual(expect.objectContaining({ accessToken: 'workspace-token' })); + expect(basePath).toBe('/2.0/repositories/Acme/Widgets/refs/branches'); + expect(identity).toBe('bitbucket-branches:Acme/Widgets'); + expect(cursor).toBeUndefined(); + expect(typeof guard).toBe('function'); + expect(mockFetchPage.mock.calls[1][3]).toBe('page-2'); + expect(result).toEqual({ + defaultBranch: 'master', + branches: ['master', 'feat', 'release'], + }); + }); + + it('keeps the branch list when the repository-metadata read fails — the default is optional context', async () => { + mockRequestBitbucketJson.mockRejectedValueOnce( + new BitbucketReviewError('not_found', 'no model') + ); + mockFetchPage.mockResolvedValueOnce({ + values: [{ name: 'any', type: 'branch' }], + nextCursor: null, + }); + const result = await caller.listRepositoryBranches({ + organizationId: ORG_ID, + platform: 'bitbucket', + repository: { fullName: 'acme/widgets' }, + }); + expect(result).toEqual({ defaultBranch: null, branches: ['any'] }); + }); + + it('surfaces a retryable refs failure as a retry error, never as an empty success', async () => { + mockFetchPage.mockRejectedValueOnce( + new BitbucketReviewError('retryable', 'Bitbucket is temporarily unavailable.') + ); + await expect( + caller.listRepositoryBranches({ + organizationId: ORG_ID, + platform: 'bitbucket', + repository: { fullName: 'acme/widgets' }, + }) + ).rejects.toMatchObject({ code: 'BAD_GATEWAY' }); + }); + + it('accepts no integration id, token, or host from the client', async () => { + for (const smuggled of [ + { integrationId: 'int-1' }, + { accessToken: 'secret' }, + { instanceUrl: 'https://evil.example' }, + ]) { + await expect( + caller.listRepositoryBranches({ + organizationId: ORG_ID, + platform: 'github', + repository: { fullName: 'octocat/hello' }, + ...smuggled, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + } + expect(mockGetIntegrationsByOrganization).not.toHaveBeenCalled(); + expect(mockListBranches).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts b/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts index e939ee63ad..658c78da22 100644 --- a/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts +++ b/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts @@ -26,6 +26,11 @@ import { fetchGitLabRepositoriesForOrganization, } from '@/lib/cloud-agent/gitlab-integration-helpers'; import { orderRepositoriesByUsage } from '@/lib/cloud-agent/order-repositories'; +import { + listProviderRepositoryBranches, + ProviderBranchListingSchema, + repositoryFullNameSchema, +} from '@/lib/cloud-agent/provider-branch-listing'; import { basePrepareSessionNextSchema, basePrepareSessionNextOutputSchema, @@ -938,4 +943,31 @@ export const organizationCloudAgentNextRouter = createTRPCRouter({ }), }; }), + + /** + * List the branches of one repository for the new-session flow + * (organization context). All three providers run against the + * organization's own connection; the integration and credentials are + * resolved server-side, never supplied here. `organizationMemberProcedure` + * runs `ensureOrganizationAccess` before the resolver sees the input. + */ + listRepositoryBranches: organizationMemberProcedure + .input( + z + .object({ + organizationId: z.uuid(), + platform: z.enum(['github', 'gitlab', 'bitbucket']), + repository: z.object({ fullName: repositoryFullNameSchema }).strict(), + }) + .strict() + ) + .output(ProviderBranchListingSchema) + .query(async ({ ctx, input }) => + listProviderRepositoryBranches({ + platform: input.platform, + userId: ctx.user.id, + organizationId: input.organizationId, + repositoryFullName: input.repository.fullName, + }) + ), }); diff --git a/apps/web/src/routers/provider-review-router.test.ts b/apps/web/src/routers/provider-review-router.test.ts new file mode 100644 index 0000000000..b54e69abcd --- /dev/null +++ b/apps/web/src/routers/provider-review-router.test.ts @@ -0,0 +1,1016 @@ +/** + * @jest-environment node + */ +import { describe, expect, it, beforeAll, beforeEach } from '@jest/globals'; +// @swc/jest only hoists `jest.mock` calls when `jest` is the GLOBAL binding +// (@types/jest). Importing `jest` from '@jest/globals' defeats hoisting: the +// mocked modules load for real before registration. Same pattern as +// github-pr-review-router.test.ts. +import { TRPCError } from '@trpc/server'; +import { createCallerFactory } from '@/lib/trpc/init'; +import type { User, OperationLedgerRow } from '@kilocode/db/schema'; +import { providerPrRefKey } from '@kilocode/app-shared/provider-review'; +import { GitLabReviewError } from '@/lib/provider-review/gitlab-authorization'; +import { GITLAB_STALE_HEAD_REASON } from '@/lib/provider-review/gitlab-write'; +import { + BITBUCKET_AUTO_MERGE_UNSUPPORTED_REASON, + BITBUCKET_PR_REVIEW_CAPABILITIES, +} from '@/lib/provider-review/bitbucket-write'; +import { GITLAB_MR_REVIEW_CAPABILITIES } from '@/lib/provider-review/gitlab-write'; +import { providerLedgerResourceKey, providerReviewRouter } from './provider-review-router'; + +const ORG_ID = '2b1d4c8e-9f3a-4e5d-8c7b-6a5948372615'; +const USER_ID = 'user-1'; + +// ----- mocked seams ----------------------------------------------------------- +// Every jest.mock factory below delegates LAZILY (arrow closures) so the +// hoisted mock registration never touches the const bindings during the +// import phase. + +// The ledger primitives: the router must drive the same admission state +// machine as the GitHub write path. Mocked so the tests assert the +// orchestration (admit → execute → settle) without a database. +const mockAdmitOperation = jest.fn(); +const mockSettleOperation = jest.fn(); +const mockMarkReconcilePending = jest.fn(); +const mockRecordOperationAcceptance = jest.fn(); + +jest.mock('@kilocode/db/operation-ledger', () => ({ + admitOperation: (...args: unknown[]) => mockAdmitOperation(...args), + settleOperation: (...args: unknown[]) => mockSettleOperation(...args), + markReconcilePending: (...args: unknown[]) => mockMarkReconcilePending(...args), + recordOperationAcceptance: (...args: unknown[]) => mockRecordOperationAcceptance(...args), +})); + +// The router passes `db` to the (mocked) ledger only. +jest.mock('@/lib/drizzle', () => ({ db: {} })); + +const mockEnsureOrganizationAccess = jest.fn(); +jest.mock('./organizations/utils', () => ({ + ensureOrganizationAccess: (...args: unknown[]) => mockEnsureOrganizationAccess(...args), +})); + +const mockAssertTermsAccepted = jest.fn(); +jest.mock('./github-pr-review-router', () => ({ + assertTermsAccepted: (...args: unknown[]) => mockAssertTermsAccepted(...args), +})); + +// The provider read layers (s2/s3). The router must forward the input's +// repository identity and the ctx-derived owner — nothing else. +const gitlabRead = { + getMergeRequest: jest.fn(), + listChangedFiles: jest.fn(), + getFileLines: jest.fn(), + listDiscussions: jest.fn(), + listChecks: jest.fn(), + listInbox: jest.fn(), + getMergeState: jest.fn(), +}; +jest.mock('@/lib/provider-review/gitlab-read', () => ({ + getMergeRequest: (...a: unknown[]) => gitlabRead.getMergeRequest(...a), + listChangedFiles: (...a: unknown[]) => gitlabRead.listChangedFiles(...a), + getFileLines: (...a: unknown[]) => gitlabRead.getFileLines(...a), + listDiscussions: (...a: unknown[]) => gitlabRead.listDiscussions(...a), + listChecks: (...a: unknown[]) => gitlabRead.listChecks(...a), + listInbox: (...a: unknown[]) => gitlabRead.listInbox(...a), + getMergeState: (...a: unknown[]) => gitlabRead.getMergeState(...a), + requestGitLabJson: jest.fn(), +})); + +const bitbucketRead = { + getPullRequest: jest.fn(), + listChangedFiles: jest.fn(), + getFileLines: jest.fn(), + listDiscussions: jest.fn(), + listChecks: jest.fn(), + listInbox: jest.fn(), + getMergeRestrictions: jest.fn(), +}; +jest.mock('@/lib/provider-review/bitbucket-read', () => ({ + getPullRequest: (...a: unknown[]) => bitbucketRead.getPullRequest(...a), + listChangedFiles: (...a: unknown[]) => bitbucketRead.listChangedFiles(...a), + getFileLines: (...a: unknown[]) => bitbucketRead.getFileLines(...a), + listDiscussions: (...a: unknown[]) => bitbucketRead.listDiscussions(...a), + listChecks: (...a: unknown[]) => bitbucketRead.listChecks(...a), + listInbox: (...a: unknown[]) => bitbucketRead.listInbox(...a), + getMergeRestrictions: (...a: unknown[]) => bitbucketRead.getMergeRestrictions(...a), + requestBitbucketJson: jest.fn(), + fetchPage: jest.fn(), + repositoryPathGuard: jest.fn(), +})); + +// The write layers: real capability constants and reason copy (the tests +// assert against them), mocked effects. +const gitlabWrite = { + addComment: jest.fn(), + replyToDiscussion: jest.fn(), + submitReview: jest.fn(), + resolveThread: jest.fn(), + unresolveThread: jest.fn(), + mergePullRequest: jest.fn(), + enableAutoMerge: jest.fn(), + disableAutoMerge: jest.fn(), +}; +jest.mock('@/lib/provider-review/gitlab-write', () => ({ + ...jest.requireActual('@/lib/provider-review/gitlab-write'), + addComment: (...a: unknown[]) => gitlabWrite.addComment(...a), + replyToDiscussion: (...a: unknown[]) => gitlabWrite.replyToDiscussion(...a), + submitReview: (...a: unknown[]) => gitlabWrite.submitReview(...a), + resolveThread: (...a: unknown[]) => gitlabWrite.resolveThread(...a), + unresolveThread: (...a: unknown[]) => gitlabWrite.unresolveThread(...a), + mergePullRequest: (...a: unknown[]) => gitlabWrite.mergePullRequest(...a), + enableAutoMerge: (...a: unknown[]) => gitlabWrite.enableAutoMerge(...a), + disableAutoMerge: (...a: unknown[]) => gitlabWrite.disableAutoMerge(...a), +})); + +const bitbucketWrite = { + addComment: jest.fn(), + replyToComment: jest.fn(), + submitReview: jest.fn(), + resolveThread: jest.fn(), + unresolveThread: jest.fn(), + mergePullRequest: jest.fn(), +}; +jest.mock('@/lib/provider-review/bitbucket-write', () => ({ + ...jest.requireActual('@/lib/provider-review/bitbucket-write'), + addComment: (...a: unknown[]) => bitbucketWrite.addComment(...a), + replyToComment: (...a: unknown[]) => bitbucketWrite.replyToComment(...a), + submitReview: (...a: unknown[]) => bitbucketWrite.submitReview(...a), + resolveThread: (...a: unknown[]) => bitbucketWrite.resolveThread(...a), + unresolveThread: (...a: unknown[]) => bitbucketWrite.unresolveThread(...a), + mergePullRequest: (...a: unknown[]) => bitbucketWrite.mergePullRequest(...a), +})); + +// ----- fixtures --------------------------------------------------------------- + +const gitlabBase = { + platform: 'gitlab' as const, + projectPath: 'group/sub/repo', + mrIid: 7, +}; +const bitbucketBase = { + platform: 'bitbucket' as const, + organizationId: ORG_ID, + workspace: 'acme', + repoSlug: 'widgets', + prId: 12, +}; + +function admittedRow(overrides: Partial = {}): OperationLedgerRow { + return { + id: 'row-1', + intent: 'create_review_comment', + resource_key: 'resource-key-under-test', + status: 'admitted', + canonical_result: null, + ...overrides, + } as OperationLedgerRow; +} + +/** + * Queue an admission outcome whose row MIRRORS the request's identity + * (intent + resource key) — the router's key-reuse guard refuses a row that + * does not belong to the request, so branch tests must start from a row the + * ledger actually returned for this call. + */ +function admittingOnce(admission: string, rowOverrides: Partial = {}): void { + mockAdmitOperation.mockImplementationOnce(async (_db: unknown, args: any) => ({ + admission, + row: admittedRow({ + intent: args.intent, + resource_key: args.resourceKey, + ...rowOverrides, + }), + })); +} + +function summaryFixture(overrides: Record = {}) { + return { + ref: { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 7 }, + state: 'open', + headSha: 'a'.repeat(40), + ...overrides, + }; +} + +let caller: any; + +beforeAll(() => { + caller = createCallerFactory(providerReviewRouter)({ + user: { id: USER_ID, is_admin: false } as User, + }); +}); + +beforeEach(() => { + jest.clearAllMocks(); + mockEnsureOrganizationAccess.mockResolvedValue('member'); + mockAssertTermsAccepted.mockResolvedValue(undefined); + // Default admission: a fresh row mirroring the request's identity, so + // happy-path tests pass the reuse guard; mismatch tests override it. + mockAdmitOperation.mockImplementation(async (_db: unknown, args: any) => ({ + admission: 'admitted', + row: admittedRow({ intent: args.intent, resource_key: args.resourceKey }), + })); + mockSettleOperation.mockResolvedValue({ settled: true, row: admittedRow() }); + mockMarkReconcilePending.mockResolvedValue(admittedRow({ status: 'reconcile_pending' })); + mockRecordOperationAcceptance.mockResolvedValue(null); + gitlabRead.getMergeRequest.mockResolvedValue(summaryFixture()); + gitlabWrite.addComment.mockResolvedValue({ done: true, replayed: false }); + gitlabWrite.mergePullRequest.mockResolvedValue({ + done: true, + replayed: false, + }); + gitlabWrite.enableAutoMerge.mockResolvedValue({ + done: true, + replayed: false, + }); + gitlabWrite.disableAutoMerge.mockResolvedValue({ + done: true, + replayed: false, + }); + bitbucketWrite.addComment.mockResolvedValue({ done: true, replayed: false }); +}); + +// ----- inputs are provider-discriminated, strict, and carry no identity ------- + +describe('providerReviewRouter inputs', () => { + it('rejects host, token, instanceUrl, and userId fields on the GitLab arm', async () => { + for (const smuggled of [ + { instanceUrl: 'https://evil.example' }, + { token: 'glpat-secret' }, + { host: 'evil.example' }, + { userId: 'victim' }, + ]) { + await expect(caller.getPullRequest({ ...gitlabBase, ...smuggled })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + } + expect(gitlabRead.getMergeRequest).not.toHaveBeenCalled(); + }); + + it('requires organizationId on the Bitbucket arm', async () => { + await expect( + caller.getPullRequest({ + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'widgets', + prId: 12, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(bitbucketRead.getPullRequest).not.toHaveBeenCalled(); + }); + + it('accepts the infinite-query direction discriminator on paged inputs', async () => { + gitlabRead.listChangedFiles.mockResolvedValue({ + items: [], + nextCursor: null, + }); + await expect( + caller.listFiles({ ...gitlabBase, cursor: 'c1', direction: 'forward' }) + ).resolves.toBeDefined(); + expect(gitlabRead.listChangedFiles).toHaveBeenCalledWith( + { type: 'user', userId: USER_ID }, + 'group/sub/repo', + 7, + 'c1', + undefined + ); + }); +}); + +// ----- identity is server-derived ---------------------------------------------- + +describe('providerReviewRouter identity derivation', () => { + it('runs ensureOrganizationAccess before any provider call when an organizationId is present', async () => { + gitlabRead.getMergeRequest.mockResolvedValue(summaryFixture()); + await caller.getPullRequest({ ...gitlabBase, organizationId: ORG_ID }); + expect(mockEnsureOrganizationAccess).toHaveBeenCalledWith( + expect.objectContaining({ + user: expect.objectContaining({ id: USER_ID }), + }), + ORG_ID + ); + expect(gitlabRead.getMergeRequest).toHaveBeenCalledWith( + { type: 'organization', organizationId: ORG_ID, userId: USER_ID }, + 'group/sub/repo', + 7, + undefined + ); + }); + + it('derives the personal owner from ctx.user, never from input', async () => { + gitlabRead.getMergeRequest.mockResolvedValue(summaryFixture()); + await caller.getPullRequest(gitlabBase); + expect(mockEnsureOrganizationAccess).not.toHaveBeenCalled(); + expect(gitlabRead.getMergeRequest).toHaveBeenCalledWith( + { type: 'user', userId: USER_ID }, + 'group/sub/repo', + 7, + undefined + ); + }); + + it('stops before any provider call when the organization guard rejects', async () => { + mockEnsureOrganizationAccess.mockRejectedValueOnce( + new TRPCError({ code: 'FORBIDDEN', message: 'no access' }) + ); + await expect( + caller.addComment({ + ...bitbucketBase, + body: 'hi', + operationKey: 'key-1', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(bitbucketWrite.addComment).not.toHaveBeenCalled(); + expect(mockAdmitOperation).not.toHaveBeenCalled(); + }); + + it('passes instanceHint only as a hint to the authorization layer, with the server-derived owner', async () => { + gitlabRead.getMergeRequest.mockResolvedValue(summaryFixture()); + await caller.getPullRequest({ + ...gitlabBase, + instanceHint: 'gitlab.example', + }); + // The hint arrives as the LAST positional argument of the read layer — + // the layer matches it against the connected instance and refuses a + // mismatch (gitlab-authorization.test.ts); the router never builds a + // request from it. + expect(gitlabRead.getMergeRequest).toHaveBeenCalledWith( + { type: 'user', userId: USER_ID }, + 'group/sub/repo', + 7, + 'gitlab.example' + ); + }); + + it('never lets a page cursor steer which repository is read', async () => { + gitlabRead.listChangedFiles.mockResolvedValue({ + items: [], + nextCursor: null, + }); + // A cursor minted for another repository is still only an opaque page + // pointer: the router forwards the INPUT's identity, and the provider + // cursor codec (s2) refuses a cursor bound to a different identity. + await caller.listFiles({ + ...gitlabBase, + cursor: Buffer.from( + JSON.stringify({ + identity: 'gitlab-diff:other/repo#1', + next: 'https://x/other%2Frepo', + }) + ).toString('base64url'), + }); + expect(gitlabRead.listChangedFiles).toHaveBeenCalledWith( + { type: 'user', userId: USER_ID }, + 'group/sub/repo', + 7, + expect.any(String), + undefined + ); + }); +}); + +// ----- the shared operation ledger ------------------------------------------------ + +describe('providerReviewRouter ledger', () => { + it('admits provider writes into the shared pr domain with a provider-tagged resource key', async () => { + await caller.addComment({ + ...gitlabBase, + body: 'hello', + operationKey: 'key-1', + }); + const expectedKey = providerLedgerResourceKey( + 'create_review_comment', + { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 7 }, + { + platform: 'gitlab', + projectPath: 'group/sub/repo', + instanceHint: undefined, + number: 7, + body: 'hello', + } + ); + expect(mockAdmitOperation).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + userId: USER_ID, + domain: 'pr', + intent: 'create_review_comment', + operationKey: 'key-1', + taxonomy: 'reconcile-first', + resourceKey: expectedKey, + }) + ); + // The resource key carries the provider identity, not the GitHub + // `owner/repo#number` shape. + expect(expectedKey.startsWith(JSON.stringify(['gitlab', '', 'group/sub/repo', 7]))).toBe(true); + }); + + it('a GitLab comment and a same-named GitHub comment can never share a ledger key', () => { + const gitlabKey = providerLedgerResourceKey( + 'create_review_comment', + { platform: 'gitlab', projectPath: 'octocat/hello', mrIid: 1 }, + { + platform: 'gitlab', + projectPath: 'octocat/hello', + number: 1, + body: 'same text', + } + ); + // The GitHub ledger identity (prLedgerResourceKey) is + // `owner/repo#number::hash` — a plain string prefix. + const githubStyle = 'octocat/hello#1::'; + expect(gitlabKey.startsWith(githubStyle)).toBe(false); + expect( + gitlabKey.startsWith( + providerPrRefKey({ + platform: 'gitlab', + projectPath: 'octocat/hello', + mrIid: 1, + }) + ) + ).toBe(true); + const bitbucketKey = providerLedgerResourceKey( + 'create_review_comment', + { + platform: 'bitbucket', + workspace: 'octocat', + repoSlug: 'hello', + prId: 1, + }, + { + platform: 'bitbucket', + workspace: 'octocat', + repoSlug: 'hello', + number: 1, + body: 'same text', + } + ); + expect(bitbucketKey.startsWith(githubStyle)).toBe(false); + expect(bitbucketKey).not.toEqual(gitlabKey); + }); + + it('settles a completed write with the pr_operation_settled outbox event', async () => { + await caller.addComment({ + ...gitlabBase, + body: 'hello', + operationKey: 'key-1', + }); + expect(mockSettleOperation).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + rowId: 'row-1', + status: 'completed', + outcomeCode: 'ok', + canonicalResult: { done: true, replayed: false }, + }) + ); + const event = (mockSettleOperation.mock.calls[0][1] as { outboxEvent: any }).outboxEvent; + expect(event.eventName).toBe('pr_operation_settled'); + expect(event.distinctId).toBe(USER_ID); + expect(event.properties).toMatchObject({ + intent: 'create_review_comment', + outcome: 'completed', + surface: 'pr', + }); + }); + + it('replays a settled duplicate without re-executing the provider write', async () => { + admittingOnce('duplicate_settled', { + status: 'completed', + canonical_result: { done: true, replayed: false }, + }); + await expect( + caller.addComment({ + ...gitlabBase, + body: 'hello', + operationKey: 'key-1', + }) + ).resolves.toEqual({ done: true, replayed: true }); + expect(gitlabWrite.addComment).not.toHaveBeenCalled(); + }); + + it('refuses a key reused for a different intent with no effect and no replay', async () => { + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'admitted', + row: admittedRow({ intent: 'merge' }), + }); + await expect( + caller.addComment({ + ...gitlabBase, + body: 'hello', + operationKey: 'key-1', + }) + ).rejects.toMatchObject({ + code: 'CONFLICT', + message: 'operation_key_reuse_mismatch', + }); + expect(gitlabWrite.addComment).not.toHaveBeenCalled(); + expect(mockSettleOperation).not.toHaveBeenCalled(); + }); + + it('never re-executes an in-flight duplicate', async () => { + admittingOnce('duplicate_in_flight'); + await expect( + caller.addComment({ + ...gitlabBase, + body: 'hello', + operationKey: 'key-1', + }) + ).rejects.toMatchObject({ + code: 'CONFLICT', + message: 'operation_in_progress', + }); + expect(gitlabWrite.addComment).not.toHaveBeenCalled(); + }); + + it('runs the UGC terms gate before admission', async () => { + mockAssertTermsAccepted.mockRejectedValueOnce( + new TRPCError({ code: 'PRECONDITION_FAILED', message: 'terms_required' }) + ); + await expect( + caller.addComment({ + ...gitlabBase, + body: 'hello', + operationKey: 'key-1', + }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: 'terms_required', + }); + expect(mockAdmitOperation).not.toHaveBeenCalled(); + expect(gitlabWrite.addComment).not.toHaveBeenCalled(); + }); + + it('marks the row reconcile-pending on a retryable provider failure and surfaces the ambiguous marker', async () => { + gitlabWrite.addComment.mockRejectedValueOnce( + new GitLabReviewError('retryable', 'Could not reach GitLab. Please try again.') + ); + await expect( + caller.addComment({ + ...gitlabBase, + body: 'hello', + operationKey: 'key-1', + }) + ).rejects.toMatchObject({ + code: 'CONFLICT', + message: "Couldn't confirm — check the merge request before retrying.", + }); + expect(mockMarkReconcilePending).toHaveBeenCalledWith( + {}, + expect.objectContaining({ rowId: 'row-1' }) + ); + // The ambiguous row is NEVER settled terminal. + expect(mockSettleOperation).not.toHaveBeenCalled(); + }); + + it('runs unledgered writes when no operationKey is present', async () => { + await caller.addComment({ ...gitlabBase, body: 'hello' }); + expect(mockAdmitOperation).not.toHaveBeenCalled(); + expect(gitlabWrite.addComment).toHaveBeenCalledTimes(1); + }); +}); + +// ----- inline anchors ------------------------------------------------------------- + +describe('providerReviewRouter inline anchors', () => { + const anchor = { + path: 'src/a.ts', + side: 'RIGHT' as const, + line: 42, + startLine: 40, + }; + + it('passes the anchor to the GitLab write and folds it into the fingerprint', async () => { + await caller.addComment({ + ...gitlabBase, + body: 'inline', + anchor, + operationKey: 'key-1', + }); + + expect(gitlabWrite.addComment).toHaveBeenCalledWith( + expect.objectContaining({ body: 'inline', anchor }) + ); + const expectedKey = providerLedgerResourceKey( + 'create_review_comment', + { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 7 }, + { + platform: 'gitlab', + projectPath: 'group/sub/repo', + instanceHint: undefined, + number: 7, + body: 'inline', + path: 'src/a.ts', + line: 42, + side: 'RIGHT', + startLine: 40, + } + ); + expect(mockAdmitOperation).toHaveBeenCalledWith( + {}, + expect.objectContaining({ resourceKey: expectedKey }) + ); + }); + + it('passes the anchor to the Bitbucket write and folds it into the fingerprint', async () => { + await caller.addComment({ + ...bitbucketBase, + body: 'inline', + anchor, + operationKey: 'key-1', + }); + + expect(bitbucketWrite.addComment).toHaveBeenCalledWith( + expect.objectContaining({ body: 'inline', anchor }) + ); + const expectedKey = providerLedgerResourceKey( + 'create_review_comment', + { + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'widgets', + prId: 12, + }, + { + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'widgets', + number: 12, + body: 'inline', + path: 'src/a.ts', + line: 42, + side: 'RIGHT', + startLine: 40, + } + ); + expect(mockAdmitOperation).toHaveBeenCalledWith( + {}, + expect.objectContaining({ resourceKey: expectedKey }) + ); + }); + + it('an anchored and an unanchored comment with the same body never share a ledger key', async () => { + const anchored = providerLedgerResourceKey( + 'create_review_comment', + { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 7 }, + { + platform: 'gitlab', + projectPath: 'group/sub/repo', + number: 7, + body: 'same', + path: 'src/a.ts', + line: 42, + side: 'RIGHT', + } + ); + const plain = providerLedgerResourceKey( + 'create_review_comment', + { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 7 }, + { + platform: 'gitlab', + projectPath: 'group/sub/repo', + number: 7, + body: 'same', + } + ); + expect(anchored).not.toEqual(plain); + }); + + it('without an anchor the write payload and the fingerprint bytes stay unchanged', async () => { + await caller.addComment({ + ...gitlabBase, + body: 'hello', + operationKey: 'key-1', + }); + + expect(gitlabWrite.addComment).toHaveBeenCalledWith(expect.objectContaining({ body: 'hello' })); + expect(gitlabWrite.addComment.mock.calls[0][0]).not.toHaveProperty('anchor'); + // The legacy bytes: path/line/side/startLine absent (undefined) still + // serialize identically, so older clients keep replaying correctly. + const legacyKey = providerLedgerResourceKey( + 'create_review_comment', + { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 7 }, + { + platform: 'gitlab', + projectPath: 'group/sub/repo', + instanceHint: undefined, + number: 7, + body: 'hello', + } + ); + expect(mockAdmitOperation).toHaveBeenCalledWith( + {}, + expect.objectContaining({ resourceKey: legacyKey }) + ); + }); + + it('refuses malformed anchors with BAD_REQUEST before any write', async () => { + for (const bad of [ + { path: 'a.ts', side: 'TOP', line: 1 }, + { path: 'a.ts', side: 'LEFT', line: 0 }, + { path: 'a.ts', side: 'LEFT', line: -3 }, + { path: '', side: 'LEFT', line: 1 }, + { path: 'a.ts', side: 'LEFT', line: 1.5 }, + { path: 'a.ts', side: 'LEFT', line: 1, startLine: 2 }, + { path: 'a.ts', side: 'LEFT', line: 1, extra: true }, + { side: 'LEFT', line: 1 }, + ]) { + await expect( + caller.addComment({ + ...gitlabBase, + body: 'x', + anchor: bad, + operationKey: 'k', + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + } + expect(gitlabWrite.addComment).not.toHaveBeenCalled(); + }); + + it('submitReview folds the comment batch into the write and the fingerprint', async () => { + gitlabWrite.submitReview.mockResolvedValueOnce({ + done: true, + replayed: false, + }); + const comments = [ + { path: 'a.ts', side: 'RIGHT' as const, line: 3, body: 'first' }, + { + path: 'b.ts', + side: 'LEFT' as const, + line: 9, + startLine: 4, + body: 'second', + }, + ]; + + await caller.submitReview({ + ...gitlabBase, + event: 'approve', + body: 'LGTM', + comments, + operationKey: 'key-1', + }); + + expect(gitlabWrite.submitReview).toHaveBeenCalledWith(expect.objectContaining({ comments })); + const expectedKey = providerLedgerResourceKey( + 'submit_review', + { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 7 }, + { + platform: 'gitlab', + projectPath: 'group/sub/repo', + instanceHint: undefined, + number: 7, + event: 'approve', + body: 'LGTM', + comments, + } + ); + expect(mockAdmitOperation).toHaveBeenCalledWith( + {}, + expect.objectContaining({ resourceKey: expectedKey }) + ); + }); + + it('Bitbucket submitReview carries the batch through the same path', async () => { + bitbucketWrite.submitReview.mockResolvedValueOnce({ + done: true, + replayed: false, + }); + const comments = [{ path: 'a.ts', side: 'RIGHT' as const, line: 3, body: 'first' }]; + + await caller.submitReview({ + ...bitbucketBase, + event: 'comment', + comments, + operationKey: 'key-1', + }); + + expect(bitbucketWrite.submitReview).toHaveBeenCalledWith( + expect.objectContaining({ event: 'comment', comments }) + ); + }); + + it('a submit without comments keeps the legacy fingerprint bytes', async () => { + gitlabWrite.submitReview.mockResolvedValueOnce({ + done: true, + replayed: false, + }); + await caller.submitReview({ + ...gitlabBase, + event: 'approve', + body: 'LGTM', + operationKey: 'key-1', + }); + + expect(gitlabWrite.submitReview.mock.calls[0][0]).not.toHaveProperty('comments'); + const legacyKey = providerLedgerResourceKey( + 'submit_review', + { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 7 }, + { + platform: 'gitlab', + projectPath: 'group/sub/repo', + instanceHint: undefined, + number: 7, + event: 'approve', + body: 'LGTM', + } + ); + expect(mockAdmitOperation).toHaveBeenCalledWith( + {}, + expect.objectContaining({ resourceKey: legacyKey }) + ); + }); + + it('refuses comment items and oversized batches with BAD_REQUEST before any write', async () => { + await expect( + caller.submitReview({ + ...gitlabBase, + event: 'comment', + comments: [{ path: 'a.ts', side: 'RIGHT', line: 1 }], + operationKey: 'k', + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect( + caller.submitReview({ + ...gitlabBase, + event: 'comment', + comments: Array.from({ length: 101 }, (_, i) => ({ + path: 'a.ts', + side: 'RIGHT' as const, + line: i + 1, + body: 'x', + })), + operationKey: 'k', + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(gitlabWrite.submitReview).not.toHaveBeenCalled(); + }); +}); + +// ----- moved head blocks merge --------------------------------------------------- + +describe('providerReviewRouter merge head fence', () => { + it('surfaces the exact stale-head reason as a CONFLICT and settles the row failed head_moved', async () => { + gitlabWrite.mergePullRequest.mockRejectedValueOnce( + new GitLabReviewError('stale_head', GITLAB_STALE_HEAD_REASON) + ); + await expect( + caller.mergePullRequest({ + ...gitlabBase, + expectedHeadSha: 'a'.repeat(40), + operationKey: 'key-merge', + }) + ).rejects.toMatchObject({ + code: 'CONFLICT', + message: GITLAB_STALE_HEAD_REASON, + }); + expect(mockSettleOperation).toHaveBeenCalledWith( + {}, + expect.objectContaining({ status: 'failed', outcomeCode: 'head_moved' }) + ); + expect(mockMarkReconcilePending).not.toHaveBeenCalled(); + }); + + it('reconciles a pending merge by re-reading through the owner-bound reader', async () => { + admittingOnce('duplicate_reconcile_pending', { + status: 'reconcile_pending', + }); + gitlabRead.getMergeRequest.mockResolvedValueOnce(summaryFixture({ state: 'merged' })); + await expect( + caller.mergePullRequest({ + ...gitlabBase, + expectedHeadSha: 'a'.repeat(40), + operationKey: 'key-merge', + }) + ).resolves.toMatchObject({ done: true, replayed: true }); + // The reconcile read used the input's identity with the ctx owner — the + // same authorization the write path uses. + expect(gitlabRead.getMergeRequest).toHaveBeenCalledWith( + { type: 'user', userId: USER_ID }, + 'group/sub/repo', + 7, + undefined + ); + expect(gitlabWrite.mergePullRequest).not.toHaveBeenCalled(); + expect(mockSettleOperation).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + status: 'completed', + canonicalResult: { done: true, replayed: true }, + }) + ); + }); + + it('a reconcile read showing a moved head settles failed confirmed_absent and refuses the merge', async () => { + admittingOnce('duplicate_reconcile_pending', { + status: 'reconcile_pending', + }); + gitlabRead.getMergeRequest.mockResolvedValueOnce(summaryFixture({ headSha: 'b'.repeat(40) })); + await expect( + caller.mergePullRequest({ + ...gitlabBase, + expectedHeadSha: 'a'.repeat(40), + operationKey: 'key-merge', + }) + ).rejects.toMatchObject({ + code: 'CONFLICT', + message: GITLAB_STALE_HEAD_REASON, + }); + expect(gitlabWrite.mergePullRequest).not.toHaveBeenCalled(); + expect(mockSettleOperation).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + status: 'failed', + outcomeCode: 'head_moved', + outboxEvent: expect.objectContaining({ + properties: expect.objectContaining({ + reconcile_result: 'confirmed_absent', + }), + }), + }) + ); + }); + + it('a failed authoritative read stays reconcile-pending instead of settling absent', async () => { + admittingOnce('duplicate_reconcile_pending', { + status: 'reconcile_pending', + }); + gitlabRead.getMergeRequest.mockRejectedValueOnce(new GitLabReviewError('not_found', 'gone')); + await expect( + caller.mergePullRequest({ + ...gitlabBase, + expectedHeadSha: 'a'.repeat(40), + operationKey: 'key-merge', + }) + ).rejects.toMatchObject({ code: 'CONFLICT' }); + expect(mockMarkReconcilePending).toHaveBeenCalled(); + expect(mockSettleOperation).not.toHaveBeenCalled(); + }); +}); + +// ----- capabilities and auto-merge -------------------------------------------------- + +describe('providerReviewRouter capabilities', () => { + it('answers GitLab with the MR capability list (no request-changes event)', async () => { + await expect(caller.getCapabilities({ platform: 'gitlab' })).resolves.toEqual( + GITLAB_MR_REVIEW_CAPABILITIES + ); + expect(GITLAB_MR_REVIEW_CAPABILITIES.reviewEvents).not.toContain('request_changes'); + }); + + it('answers Bitbucket with the shared capability list carrying the auto-merge reason', async () => { + await expect( + caller.getCapabilities({ platform: 'bitbucket', organizationId: ORG_ID }) + ).resolves.toEqual(BITBUCKET_PR_REVIEW_CAPABILITIES); + expect(BITBUCKET_PR_REVIEW_CAPABILITIES.autoMerge).toMatchObject({ + supported: false, + reason: BITBUCKET_AUTO_MERGE_UNSUPPORTED_REASON, + }); + }); + + it('returns the capability reason for Bitbucket auto-merge without a ledger row', async () => { + await expect( + caller.enableAutoMerge({ + ...bitbucketBase, + expectedHeadSha: 'a'.repeat(40), + operationKey: 'key-am', + }) + ).resolves.toEqual({ + supported: false, + reason: BITBUCKET_AUTO_MERGE_UNSUPPORTED_REASON, + done: false, + replayed: false, + }); + expect(mockAdmitOperation).not.toHaveBeenCalled(); + expect(mockEnsureOrganizationAccess).toHaveBeenCalled(); + await expect(caller.disableAutoMerge({ ...bitbucketBase })).resolves.toMatchObject({ + supported: false, + }); + expect(mockAdmitOperation).not.toHaveBeenCalled(); + }); + + it('runs GitLab auto-merge through the ledger with the auto-merge intents', async () => { + await expect( + caller.enableAutoMerge({ + ...gitlabBase, + expectedHeadSha: 'a'.repeat(40), + operationKey: 'key-am', + }) + ).resolves.toEqual({ + supported: true, + reason: '', + done: true, + replayed: false, + }); + expect(mockAdmitOperation).toHaveBeenCalledWith( + {}, + expect.objectContaining({ intent: 'enable_auto_merge' }) + ); + await caller.disableAutoMerge({ ...gitlabBase, operationKey: 'key-dam' }); + expect(mockAdmitOperation).toHaveBeenCalledWith( + {}, + expect.objectContaining({ intent: 'disable_auto_merge' }) + ); + }); +}); diff --git a/apps/web/src/routers/provider-review-router.ts b/apps/web/src/routers/provider-review-router.ts new file mode 100644 index 0000000000..ffd556f782 --- /dev/null +++ b/apps/web/src/routers/provider-review-router.ts @@ -0,0 +1,1473 @@ +/** + * Provider review router — GitLab merge requests and Bitbucket Cloud pull + * requests, composed as ONE tRPC surface (`providerReview`) for mobile. + * + * GitHub keeps its own `githubPrReview` router; this router never touches it + * except to reuse the UGC Terms gate. Inputs are provider-discriminated and + * carry NO host, NO token, NO instanceUrl: every credential, instance, and + * repository identity is re-derived per call by the s2/s3 authorization layer + * (gitlab-authorization.ts / bitbucket-authorization.ts), so a client hint + * can never pick the host. An organizationId on any input runs + * `ensureOrganizationAccess` before anything else. + * + * Write mutations accept an `operationKey` and run through the shared + * operation ledger exactly like the GitHub write path (admitOperation / + * settleOperation from @kilocode/db/operation-ledger). The intent + * fingerprint comes from s1 with provider identity, so a GitLab comment and + * a same-named GitHub comment can never share a ledger key. + */ +import 'server-only'; + +import * as z from 'zod'; +import { createHash } from 'node:crypto'; +import { TRPCError } from '@trpc/server'; + +import { baseProcedure, createTRPCRouter, type TRPCContext } from '@/lib/trpc/init'; +import { db } from '@/lib/drizzle'; +import type { OperationLedgerRow } from '@kilocode/db/schema'; +import { PR_OPERATION_SETTLED_EVENT } from '@kilocode/app-shared/analytics'; +import { prIntentFingerprint, type PrLedgerIntent } from '@kilocode/app-shared/pr-review'; +import { + providerPrRefKey, + providerPrTerm, + type ProviderPrPlatform, + type ProviderPrRef, + type ProviderPrSummary, +} from '@kilocode/app-shared/provider-review'; +import { + admitOperation, + markReconcilePending, + recordOperationAcceptance, + settleOperation, + type OutboxEventInput, +} from '@kilocode/db/operation-ledger'; +import { ensureOrganizationAccess } from './organizations/utils'; +import { assertTermsAccepted } from './github-pr-review-router'; +import { GitLabReviewError } from '@/lib/provider-review/gitlab-authorization'; +import { BitbucketReviewError } from '@/lib/provider-review/bitbucket-authorization'; +import * as gitlabRead from '@/lib/provider-review/gitlab-read'; +import { + GITLAB_MR_REVIEW_CAPABILITIES, + addComment as gitlabAddComment, + disableAutoMerge as gitlabDisableAutoMerge, + enableAutoMerge as gitlabEnableAutoMerge, + mergePullRequest as gitlabMerge, + replyToDiscussion as gitlabReplyToComment, + resolveThread as gitlabResolveThread, + submitReview as gitlabSubmitReview, + unresolveThread as gitlabUnresolveThread, +} from '@/lib/provider-review/gitlab-write'; +import * as bitbucketRead from '@/lib/provider-review/bitbucket-read'; +import { + BITBUCKET_AUTO_MERGE_UNSUPPORTED_REASON, + BITBUCKET_PR_REVIEW_CAPABILITIES, + addComment as bitbucketAddComment, + mergePullRequest as bitbucketMerge, + replyToComment as bitbucketReplyToComment, + resolveThread as bitbucketResolveThread, + submitReview as bitbucketSubmitReview, + unresolveThread as bitbucketUnresolveThread, +} from '@/lib/provider-review/bitbucket-write'; +import type { GitLabReviewOwner } from '@/lib/provider-review/gitlab-authorization'; +import type { BitbucketReviewOwner } from '@/lib/provider-review/bitbucket-authorization'; + +// ----- input schemas ---------------------------------------------------------- + +// GitLab project paths are full nested paths (`group/sub/repo`) — never just +// the last segment. The authorization layer matches them against the +// integration's repository cache; the regex only bounds the shape. +const gitlabProjectPathRegex = /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+$/; +const bitbucketSlugRegex = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; + +// tRPC's `useInfiniteQuery` integration injects a `direction` discriminator +// ('forward'|'backward') into the procedure input alongside `cursor`. The +// input stays `.strict()` (unknown fields still rejected), so it must accept +// it explicitly or every infinite-query page 400s — same tolerance as +// github-pr-review-router.ts's ListFilesInput/ListInboxInput. +const infiniteQueryDirection = z.enum(['forward', 'backward']).optional(); +const pageCursor = z.string().min(1).max(2048).optional(); + +// Client-generated UUID, stable across retries of one user intent. When +// present, the mutation admits the operation into the shared ledger and +// becomes retry-safe; when absent, the write runs unledgered (older clients). +const operationKeySchema = z.string().min(1).max(128).optional(); + +// The diff position an inline comment anchors to — the same shape the +// GitHub createReviewComment input carries (minus startSide/commitSha, which +// GitLab positions and Bitbucket inline blocks do not use). A `startLine` +// marks the first line of a multi-line range ending at `line`. +const inlineAnchorShape = { + path: z.string().min(1).max(1024), + side: z.enum(['LEFT', 'RIGHT']), + line: z.number().int().positive(), + startLine: z.number().int().positive().optional(), +}; +const startLineOrderIssue = { + message: 'startLine must be <= line', + path: ['startLine'], +}; + +const providerInlineAnchorInput = z + .object(inlineAnchorShape) + .strict() + .refine( + value => value.startLine === undefined || value.startLine <= value.line, + startLineOrderIssue + ); + +const providerInlineCommentInput = z + .object({ ...inlineAnchorShape, body: z.string().min(1).max(65_535) }) + .strict() + .refine( + value => value.startLine === undefined || value.startLine <= value.line, + startLineOrderIssue + ); + +const gitlabIdentityShape = { + platform: z.literal('gitlab'), + organizationId: z.uuid().optional(), + projectPath: z.string().regex(gitlabProjectPathRegex).max(1024), + mrIid: z.number().int().positive(), + // Display/matching only — the authorization layer refuses a hint whose + // origin differs from the connected instance; it is never an API base. + instanceHint: z.string().min(1).max(2048).optional(), +}; + +const bitbucketIdentityShape = { + platform: z.literal('bitbucket'), + // Bitbucket Cloud is organization-context only: the id is required and the + // org guard always runs. + organizationId: z.uuid(), + workspace: z.string().regex(bitbucketSlugRegex).max(100), + repoSlug: z.string().regex(bitbucketSlugRegex).max(100), + prId: z.number().int().positive(), +}; + +/** One provider-discriminated PR/MR ref input, `.strict()` on both arms. */ +function providerRefInput(extra: T) { + return z.discriminatedUnion('platform', [ + z.object({ ...gitlabIdentityShape, ...extra }).strict(), + z.object({ ...bitbucketIdentityShape, ...extra }).strict(), + ]); +} + +/** The ref-only identity (inbox, capabilities): no repository to pin. */ +const providerIdentityInput = z.discriminatedUnion('platform', [ + z + .object({ + platform: z.literal('gitlab'), + organizationId: z.uuid().optional(), + instanceHint: z.string().min(1).max(2048).optional(), + }) + .strict(), + z.object({ platform: z.literal('bitbucket'), organizationId: z.uuid() }).strict(), +]); + +const GetPullRequestInput = providerRefInput({}); + +const ListFilesInput = providerRefInput({ + cursor: pageCursor, + direction: infiniteQueryDirection, +}); + +const ListDiscussionsInput = providerRefInput({ + cursor: pageCursor, + direction: infiniteQueryDirection, +}); + +const ListChecksInput = providerRefInput({}); + +const ListInboxInput = z.discriminatedUnion('platform', [ + z + .object({ + platform: z.literal('gitlab'), + organizationId: z.uuid().optional(), + instanceHint: z.string().min(1).max(2048).optional(), + cursor: pageCursor, + direction: infiniteQueryDirection, + }) + .strict(), + z + .object({ + platform: z.literal('bitbucket'), + organizationId: z.uuid(), + cursor: pageCursor, + direction: infiniteQueryDirection, + }) + .strict(), +]); + +const GetCapabilitiesInput = providerIdentityInput; + +const GetMergeStateInput = providerRefInput({}); + +const GetFileLinesInput = providerRefInput({ + ref: z.string().min(1).max(255), + path: z.string().min(1).max(1024), + startLine: z.number().int().positive(), + endLine: z.number().int().positive(), +}); + +const AddCommentInput = providerRefInput({ + body: z.string().min(1).max(65_535), + // An optional diff anchor turns the comment into a real inline discussion + // (GitLab) / inline comment (Bitbucket); without it the write stays a + // top-level note. s1's create_review_comment fingerprint already folds + // path/line/side/startLine, so an anchored and an unanchored comment can + // never share a ledger key. + anchor: providerInlineAnchorInput.optional(), + operationKey: operationKeySchema, +}); + +const ReplyToCommentInput = z.discriminatedUnion('platform', [ + // GitLab replies land inside a discussion; the discussion id is the thread. + z + .object({ + ...gitlabIdentityShape, + discussionId: z.string().min(1).max(256), + body: z.string().min(1).max(65_535), + operationKey: operationKeySchema, + }) + .strict(), + // Bitbucket replies attach to a parent comment. + z + .object({ + ...bitbucketIdentityShape, + commentId: z.string().min(1).max(64), + body: z.string().min(1).max(65_535), + operationKey: operationKeySchema, + }) + .strict(), +]); + +const SubmitReviewInput = providerRefInput({ + event: z.enum(['approve', 'request_changes', 'comment']), + body: z.string().min(1).max(65_535).optional(), + // The inline batch a review submits BEFORE the summary note/approval. s1's + // submit_review fingerprint already folds `comments`, so a review with a + // different batch can never replay under the same key. + comments: z.array(providerInlineCommentInput).max(100).optional(), + operationKey: operationKeySchema, +}); + +const ResolveThreadInput = z.discriminatedUnion('platform', [ + z + .object({ + ...gitlabIdentityShape, + discussionId: z.string().min(1).max(256), + operationKey: operationKeySchema, + }) + .strict(), + z + .object({ + ...bitbucketIdentityShape, + threadId: z.string().min(1).max(64), + operationKey: operationKeySchema, + }) + .strict(), +]); + +const MergePullRequestInput = z.discriminatedUnion('platform', [ + z + .object({ + ...gitlabIdentityShape, + expectedHeadSha: z.string().min(6).max(64), + squash: z.boolean().optional(), + deleteBranch: z.boolean().optional(), + commitTitle: z.string().min(1).max(255).optional(), + commitMessage: z.string().min(1).max(65_535).optional(), + operationKey: operationKeySchema, + }) + .strict(), + z + .object({ + ...bitbucketIdentityShape, + expectedHeadSha: z.string().min(6).max(64), + deleteBranch: z.boolean().optional(), + commitMessage: z.string().min(1).max(65_535).optional(), + operationKey: operationKeySchema, + }) + .strict(), +]); + +// Arming auto-merge requires the head fence: GitLab's merge endpoint arms +// merge-when-pipeline-succeeds only while a pipeline runs, and the sha ties +// the arming to the exact revision the reviewer saw. Cancelling arms nothing, +// so the disable input keeps the fence optional. +const EnableAutoMergeInput = providerRefInput({ + expectedHeadSha: z.string().min(6).max(64), + operationKey: operationKeySchema, +}); + +const DisableAutoMergeInput = providerRefInput({ + expectedHeadSha: z.string().min(6).max(64).optional(), + operationKey: operationKeySchema, +}); + +// ----- owner + identity helpers ----------------------------------------------- + +/** + * Resolve the review owner. An organizationId runs `ensureOrganizationAccess` + * (the guard from organizations/utils.ts, unchanged) BEFORE any provider + * call; the acting user id always comes from `ctx.user`, never from input. + */ +async function gitlabOwner( + ctx: TRPCContext, + input: { organizationId?: string } +): Promise { + if (input.organizationId) { + await ensureOrganizationAccess(ctx, input.organizationId); + return { + type: 'organization', + organizationId: input.organizationId, + userId: ctx.user.id, + }; + } + return { type: 'user', userId: ctx.user.id }; +} + +async function bitbucketOwner( + ctx: TRPCContext, + input: { organizationId: string } +): Promise { + await ensureOrganizationAccess(ctx, input.organizationId); + return { + type: 'organization', + organizationId: input.organizationId, + userId: ctx.user.id, + }; +} + +function providerRef(input: { + platform: ProviderPrPlatform; + projectPath?: string; + mrIid?: number; + instanceHint?: string; + workspace?: string; + repoSlug?: string; + prId?: number; +}): ProviderPrRef { + if (input.platform === 'gitlab') { + return { + platform: 'gitlab', + projectPath: String(input.projectPath), + mrIid: Number(input.mrIid), + instanceHint: input.instanceHint, + }; + } + return { + platform: 'bitbucket', + workspace: String(input.workspace), + repoSlug: String(input.repoSlug), + prId: Number(input.prId), + }; +} + +/** + * Map one classified provider failure onto the mobile error states. + * `retryable` becomes BAD_GATEWAY (the ledger's ambiguous marker), a moved + * head becomes CONFLICT carrying the exact stale-head reason, the rest are + * deterministic rejections. The provider message is fixed copy that never + * embeds a token or an instance URL. + */ +function toProviderTrpcError(error: unknown): TRPCError { + if (error instanceof TRPCError) return error; + if (error instanceof GitLabReviewError || error instanceof BitbucketReviewError) { + switch (error.kind) { + case 'not_found': + return new TRPCError({ code: 'NOT_FOUND', message: error.message }); + case 'forbidden': + return new TRPCError({ code: 'FORBIDDEN', message: error.message }); + case 'stale_head': + return new TRPCError({ code: 'CONFLICT', message: error.message }); + case 'bad_request': + return new TRPCError({ code: 'BAD_REQUEST', message: error.message }); + case 'retryable': + return new TRPCError({ code: 'BAD_GATEWAY', message: error.message }); + } + } + return new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'The review request failed. Please try again.', + }); +} + +/** Run one provider read/write and surface only classified tRPC errors. */ +async function providerCall(work: () => Promise): Promise { + try { + return await work(); + } catch (error) { + throw toProviderTrpcError(error); + } +} + +// ----- PR operation ledger ------------------------------------------------------ + +// Same shared ledger, domain, lease, and admission state machine as the +// GitHub write path (github-pr-review-router.ts). The GitHub helpers are +// private and coupled to its token-retry wrapper, so this router reuses the +// exported ledger primitives (admitOperation/settleOperation/…) and the s1 +// fingerprint instead of extracting that plumbing. +const PROVIDER_LEDGER_DOMAIN = 'pr' as const; +const PROVIDER_LEDGER_LEASE_SECONDS = 120; + +const OPERATION_IN_PROGRESS_MESSAGE = 'operation_in_progress'; +const OPERATION_KEY_REUSE_MISMATCH_MESSAGE = 'operation_key_reuse_mismatch'; +const PROVIDER_REPLAY_FAILED_MESSAGE = 'This action did not complete. Please try again.'; +// The provider effect committed but the settle failed: the row is still +// non-terminal, so a success receipt would falsely claim a retry-safe replay. +const PROVIDER_LEDGER_SETTLE_FAILED_MESSAGE = + 'The action completed, but we could not record the result. Please try again.'; +// The reconcile-pending write failed, so the ambiguous marker's promise (a +// same-key retry reconciles instead of re-executing) does not hold. +const PROVIDER_LEDGER_PERSISTENCE_FAILED_MESSAGE = + 'We could not record this action. Please try again later.'; + +/** + * The provider ledger resource identity: the s1 canonical ref key (platform + * + normalized instance origin + repository path + number — so a GitLab + * comment and a same-named GitHub comment can never share a ledger key) plus + * a hash of the s1 intent fingerprint. Exported so router tests can build the + * exact stored identity. + */ +export function providerLedgerResourceKey( + intent: PrLedgerIntent, + ref: ProviderPrRef, + fingerprintInput: Record +): string { + const fingerprint = createHash('sha256') + .update(prIntentFingerprint(intent, fingerprintInput)) + .digest('hex') + .slice(0, 16); + return `${providerPrRefKey(ref)}::${fingerprint}`; +} + +/** + * The fingerprint input: the provider identity fields s1 folds into the + * resource (platform, projectPath/workspace, mrIid/prId as `number`, the + * GitLab instance hint) plus the intent-defining fields. + */ +function gitlabFingerprintInput( + input: { projectPath: string; mrIid: number; instanceHint?: string }, + fields: Record +): Record { + return { + platform: 'gitlab', + projectPath: input.projectPath, + instanceHint: input.instanceHint, + number: input.mrIid, + ...fields, + }; +} + +function bitbucketFingerprintInput( + input: { workspace: string; repoSlug: string; prId: number }, + fields: Record +): Record { + return { + platform: 'bitbucket', + workspace: input.workspace, + repoSlug: input.repoSlug, + number: input.prId, + ...fields, + }; +} + +function ambiguousProviderError(platform: ProviderPrPlatform): TRPCError { + return new TRPCError({ + code: 'CONFLICT', + message: `Couldn't confirm — check the ${providerPrTerm(platform)} before retrying.`, + }); +} + +/** + * Best-effort ledger write, reserved for FAILED-status settles only: the + * caller is already receiving a typed rejection, so a ledger write that fails + * here must never mask the provider outcome. + */ +async function bestEffortLedgerWrite(work: () => Promise): Promise { + try { + await work(); + } catch (error) { + console.error( + `Failed to write provider PR operation ledger row: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +/** `pr_operation_settled` outbox payload (DEC-05): no free text, no resource keys. */ +function providerSettledOutboxEvent(params: { + distinctId: string; + intent: PrLedgerIntent; + outcome: 'completed' | 'failed' | 'ambiguous'; + reconcileResult?: 'confirmed_completed' | 'confirmed_absent' | 'unresolved'; + startedAt: number; +}): OutboxEventInput { + return { + eventName: PR_OPERATION_SETTLED_EVENT, + distinctId: params.distinctId, + properties: { + source: 'web', + surface: 'pr', + phase: 'terminal', + intent: params.intent, + outcome: params.outcome, + ...(params.reconcileResult !== undefined ? { reconcile_result: params.reconcileResult } : {}), + duration_ms: Math.max(0, Date.now() - params.startedAt), + }, + }; +} + +type ReplayedResult = T & { replayed: true }; + +/** The canonical result replayed under the same key carries `replayed: true`. */ +interface ProviderLedgerBase { + userId: string; + /** Analytics identity channel, from `ctx.user` — never re-queried. */ + distinctId: string; + intent: PrLedgerIntent; + startedAt: number; + platform: ProviderPrPlatform; +} + +/** + * Settles a provider-confirmed outcome as `completed`. The effect committed, + * so a settle that fails must never be swallowed: the canonical evidence is + * preserved on the still non-terminal row and a retryable server error is + * thrown — never a false "did not complete" for a committed write. + */ +async function settleCompletedProviderRow( + base: ProviderLedgerBase, + row: OperationLedgerRow, + canonicalResult: Record, + reconcileResult?: 'confirmed_completed' +): Promise { + try { + await settleOperation(db, { + rowId: row.id, + status: 'completed', + outcomeCode: 'ok', + canonicalResult, + outboxEvent: providerSettledOutboxEvent({ + distinctId: base.distinctId, + intent: base.intent, + outcome: 'completed', + reconcileResult, + startedAt: base.startedAt, + }), + }); + } catch (error) { + // The provider layer reports no external reference (no comment id, no + // review id), so only the canonical evidence is preserved. + await bestEffortLedgerWrite(() => + recordOperationAcceptance(db, { + rowId: row.id, + providerRef: null, + canonicalResult, + }) + ); + console.error( + `Failed to settle completed provider PR operation ledger row: ${error instanceof Error ? error.message : String(error)}` + ); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: PROVIDER_LEDGER_SETTLE_FAILED_MESSAGE, + cause: error, + }); + } +} + +/** Best-effort `failed` settle; the caller is already surfacing a typed rejection. */ +async function settleFailedProviderRow( + base: ProviderLedgerBase, + row: OperationLedgerRow, + outcomeCode: string, + reconcileResult?: 'confirmed_absent' +): Promise { + await bestEffortLedgerWrite(() => + settleOperation(db, { + rowId: row.id, + status: 'failed', + outcomeCode, + outboxEvent: providerSettledOutboxEvent({ + distinctId: base.distinctId, + intent: base.intent, + outcome: 'failed', + reconcileResult, + startedAt: base.startedAt, + }), + }) + ); +} + +/** + * Marks the row `reconcile_pending` and then throws the ambiguous CONFLICT — + * never returns. If persistence fails the row stays `admitted` and a same-key + * retry could re-execute a possibly-committed write, so the distinct + * non-retryable persistence error is thrown instead of the ambiguous marker. + */ +async function failProviderRowAmbiguous( + base: ProviderLedgerBase, + row: OperationLedgerRow +): Promise { + try { + const updated = await markReconcilePending(db, { + rowId: row.id, + outboxEvent: providerSettledOutboxEvent({ + distinctId: base.distinctId, + intent: base.intent, + outcome: 'ambiguous', + reconcileResult: 'unresolved', + startedAt: base.startedAt, + }), + }); + if (!updated || updated.status !== 'reconcile_pending') { + throw new Error('markReconcilePending did not leave the row reconcile_pending'); + } + } catch (error) { + console.error( + `Failed to mark provider PR operation ledger row reconcile-pending: ${error instanceof Error ? error.message : String(error)}` + ); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: PROVIDER_LEDGER_PERSISTENCE_FAILED_MESSAGE, + cause: error, + }); + } + throw ambiguousProviderError(base.platform); +} + +/** + * Coarse ledger outcome code derived from the classified failure. The + * stale-head kind is folded to `head_moved` from the ORIGINAL error (the + * fixed CONFLICT copy contains no 'head' word to match on); everything else + * follows the tRPC code, mirroring `outcomeCodeFromTrpcError` in the GitHub + * write path. + */ +function outcomeCodeFromFailure(error: unknown, trpcError: TRPCError): string { + if (error instanceof GitLabReviewError || error instanceof BitbucketReviewError) { + if (error.kind === 'stale_head') return 'head_moved'; + } + switch (trpcError.code) { + case 'NOT_FOUND': + return 'not_found'; + case 'PRECONDITION_FAILED': + return 'precondition_failed'; + case 'TOO_MANY_REQUESTS': + return 'too_many_requests'; + case 'FORBIDDEN': + return 'forbidden'; + case 'CONFLICT': + return 'conflict'; + default: + return 'bad_request'; + } +} + +/** + * Whether a classified failure leaves the effect's presence unknown. A + * retryable provider failure (BAD_GATEWAY) may have committed; for merge, a + * NOT_FOUND is a read failure (the merge begins with an authoritative read), + * never a confirmed rejection — same rule as the GitHub write path. + */ +function isAmbiguousFailure(error: TRPCError, intent: PrLedgerIntent): boolean { + if (error.code === 'BAD_GATEWAY') return true; + return intent === 'merge' && error.code === 'NOT_FOUND'; +} + +/** + * Runs the provider write under an admitted row and settles it. A + * deterministic rejection settles `failed` and rethrows the classified error; + * an ambiguous failure becomes `reconcile_pending` and never settles terminal. + */ +async function executeProviderWrite>( + base: ProviderLedgerBase, + row: OperationLedgerRow, + write: () => Promise +): Promise { + let canonical: T; + try { + canonical = await write(); + } catch (error) { + const trpcError = toProviderTrpcError(error); + if (isAmbiguousFailure(trpcError, base.intent)) { + return failProviderRowAmbiguous(base, row); + } + await settleFailedProviderRow(base, row, outcomeCodeFromFailure(error, trpcError)); + throw trpcError; + } + // The write committed: settle completed at the committed-effect boundary. + await settleCompletedProviderRow(base, row, canonical); + return canonical; +} + +/** Replays a terminal row: only `completed`/`no_op` may replay a canonical result. */ +function replaySettledProviderRow(row: OperationLedgerRow): ReplayedResult { + if (row.status === 'completed' || row.status === 'no_op') { + return { + ...(row.canonical_result ?? {}), + replayed: true, + } as ReplayedResult; + } + // A settled `failed` row cannot be recovered under the same key: surface a + // non-retryable typed rejection so the client starts a fresh intent. + throw new TRPCError({ + code: 'BAD_REQUEST', + message: PROVIDER_REPLAY_FAILED_MESSAGE, + }); +} + +type ProviderLedgerMutationArgs = ProviderLedgerBase & { + operationKey: string; + resourceKey: string; + /** Runs the provider effect under an already-admitted row. */ + execute: (row: OperationLedgerRow) => Promise; + /** + * Reconcilies a same-key retry before any effect. `'re-execute'` is only + * valid for idempotent writes (the provider layer detects the target state + * and reports `replayed`); comment-like intents pass a reconciler that + * stays reconcile-pending instead of risking a duplicate write. + */ + reconcile: (row: OperationLedgerRow) => Promise>; +}; + +/** + * Ledger orchestration for a provider mutation — the same admission state + * machine as the GitHub write path: + * - `admitted`: run the effect and settle completed / failed / reconcile-pending. + * - `duplicate_settled`: replay the sanitized canonical result marked replayed. + * - `duplicate_in_flight` / `duplicate_reconcile_in_progress`: CONFLICT + * `operation_in_progress` (never re-execute). + * - `takeover` / `duplicate_reconcile_pending`: reconcile before any effect. + * + * Before ANY outcome is honored, the row is compared against the request's + * intent and resource identity (which embeds the provider-tagged request + * fingerprint); a mismatch refuses the key reuse with no effect and no replay. + */ +async function runProviderLedgerMutation( + args: ProviderLedgerMutationArgs +): Promise> { + const admission = await admitOperation(db, { + userId: args.userId, + domain: PROVIDER_LEDGER_DOMAIN, + intent: args.intent, + operationKey: args.operationKey, + resourceKey: args.resourceKey, + taxonomy: 'reconcile-first', + leaseSeconds: PROVIDER_LEDGER_LEASE_SECONDS, + }); + + if (admission.row.intent !== args.intent || admission.row.resource_key !== args.resourceKey) { + throw new TRPCError({ + code: 'CONFLICT', + message: OPERATION_KEY_REUSE_MISMATCH_MESSAGE, + }); + } + + switch (admission.admission) { + case 'admitted': + return args.execute(admission.row); + case 'duplicate_settled': + return replaySettledProviderRow(admission.row); + case 'duplicate_in_flight': + case 'duplicate_reconcile_in_progress': + throw new TRPCError({ + code: 'CONFLICT', + message: OPERATION_IN_PROGRESS_MESSAGE, + }); + case 'takeover': + case 'duplicate_reconcile_pending': + return args.reconcile(admission.row); + } +} + +/** + * The shared mutation runner: without an `operationKey` the write runs + * unledgered (legacy clients); with one it admits a `pr` row and only then + * runs the provider effect. `reconcileAmbiguous` is true for the non-idempotent + * comment/review intents — a same-key retry then never re-executes the write. + */ +async function runProviderMutation>(args: { + ctx: TRPCContext; + ref: ProviderPrRef; + intent: PrLedgerIntent; + fingerprintInput: Record; + operationKey: string | undefined; + write: () => Promise; + reconcileAmbiguous: boolean; +}): Promise> { + const guarded = () => providerCall(args.write); + if (args.operationKey === undefined) { + return guarded(); + } + const base: ProviderLedgerBase = { + userId: args.ctx.user.id, + distinctId: args.ctx.user.google_user_email ?? args.ctx.user.id, + intent: args.intent, + startedAt: Date.now(), + platform: args.ref.platform, + }; + const resourceKey = providerLedgerResourceKey(args.intent, args.ref, args.fingerprintInput); + const execute = (row: OperationLedgerRow) => executeProviderWrite(base, row, args.write); + return runProviderLedgerMutation({ + ...base, + operationKey: args.operationKey, + resourceKey, + execute, + reconcile: args.reconcileAmbiguous ? row => failProviderRowAmbiguous(base, row) : execute, + }); +} + +/** + * The merge reconcile: read the authoritative PR/MR state (via the caller's + * owner-bound reader — the same authorization the write path uses) before any + * effect. + * - merged → settle completed and replay; + * - closed/declined, or the head moved → the fenced merge never committed → + * settle failed (`confirmed_absent`) and surface a conflict carrying the + * exact reason; + * - open with the expected head intact → re-execute the merge under the row; + * - the authoritative read failed → stay reconcile-pending, surface ambiguous. + */ +async function reconcileMergeProviderRow>( + base: ProviderLedgerBase, + row: OperationLedgerRow, + args: { + expectedHeadSha: string; + /** Authoritative PR/MR read through the caller's owner-bound ref. */ + readSummary: () => Promise; + execute: () => Promise; + } +): Promise> { + let state: + | { kind: 'merged' } + | { kind: 'closed' } + | { kind: 'lineage_intact' } + | { kind: 'stale_head' } + | { kind: 'unresolved' } = { kind: 'unresolved' }; + try { + const summary = await args.readSummary(); + if (summary.state === 'merged') state = { kind: 'merged' }; + else if (summary.state === 'closed') state = { kind: 'closed' }; + else + state = + summary.headSha === args.expectedHeadSha + ? { kind: 'lineage_intact' } + : { kind: 'stale_head' }; + } catch { + // A failed authoritative read — including a provider NOT_FOUND (PR + // missing, access revoked, or a transient failure) — leaves the state + // `unresolved`. Only explicit provider state settles the row absent. + } + + switch (state.kind) { + case 'merged': { + const canonical = { done: true, replayed: true }; + await settleCompletedProviderRow(base, row, canonical, 'confirmed_completed'); + return { ...canonical, replayed: true } as unknown as ReplayedResult; + } + case 'closed': + case 'stale_head': + await settleFailedProviderRow( + base, + row, + state.kind === 'closed' ? 'already_closed' : 'head_moved', + 'confirmed_absent' + ); + throw new TRPCError({ + code: 'CONFLICT', + message: + state.kind === 'stale_head' + ? `The ${providerPrTerm(base.platform)} changed since it was loaded. Reload the ${providerPrTerm(base.platform)} and try again.` + : `The ${providerPrTerm(base.platform)} was closed without merging.`, + }); + case 'lineage_intact': + return executeProviderWrite(base, row, args.execute); + case 'unresolved': + return failProviderRowAmbiguous(base, row); + } +} + +// ----- router ------------------------------------------------------------------ + +export const providerReviewRouter = createTRPCRouter({ + getPullRequest: baseProcedure.input(GetPullRequestInput).query(async ({ ctx, input }) => { + if (input.platform === 'gitlab') { + const owner = await gitlabOwner(ctx, input); + return providerCall(() => + gitlabRead.getMergeRequest(owner, input.projectPath, input.mrIid, input.instanceHint) + ); + } + const owner = await bitbucketOwner(ctx, input); + return providerCall(() => + bitbucketRead.getPullRequest(owner, input.workspace, input.repoSlug, input.prId) + ); + }), + + listChecks: baseProcedure.input(ListChecksInput).query(async ({ ctx, input }) => { + if (input.platform === 'gitlab') { + const owner = await gitlabOwner(ctx, input); + return providerCall(() => + gitlabRead.listChecks(owner, input.projectPath, input.mrIid, input.instanceHint) + ); + } + const owner = await bitbucketOwner(ctx, input); + return providerCall(() => + bitbucketRead.listChecks(owner, input.workspace, input.repoSlug, input.prId) + ); + }), + + listFiles: baseProcedure.input(ListFilesInput).query(async ({ ctx, input }) => { + if (input.platform === 'gitlab') { + const owner = await gitlabOwner(ctx, input); + return providerCall(() => + gitlabRead.listChangedFiles( + owner, + input.projectPath, + input.mrIid, + input.cursor, + input.instanceHint + ) + ); + } + const owner = await bitbucketOwner(ctx, input); + return providerCall(() => + bitbucketRead.listChangedFiles( + owner, + input.workspace, + input.repoSlug, + input.prId, + input.cursor + ) + ); + }), + + getFileLines: baseProcedure.input(GetFileLinesInput).query(async ({ ctx, input }) => { + if (input.endLine < input.startLine) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'endLine must be >= startLine', + }); + } + if (input.platform === 'gitlab') { + const owner = await gitlabOwner(ctx, input); + return providerCall(() => + gitlabRead.getFileLines( + owner, + input.projectPath, + input.ref, + input.path, + input.startLine, + input.endLine, + input.instanceHint + ) + ); + } + const owner = await bitbucketOwner(ctx, input); + return providerCall(() => + bitbucketRead.getFileLines( + owner, + input.workspace, + input.repoSlug, + input.ref, + input.path, + input.startLine, + input.endLine + ) + ); + }), + + listDiscussions: baseProcedure.input(ListDiscussionsInput).query(async ({ ctx, input }) => { + if (input.platform === 'gitlab') { + const owner = await gitlabOwner(ctx, input); + return providerCall(() => + gitlabRead.listDiscussions( + owner, + input.projectPath, + input.mrIid, + input.cursor, + input.instanceHint + ) + ); + } + const owner = await bitbucketOwner(ctx, input); + return providerCall(() => + bitbucketRead.listDiscussions( + owner, + input.workspace, + input.repoSlug, + input.prId, + input.cursor + ) + ); + }), + + /** + * The authorized review inbox: open MRs/PRs requesting the caller's + * review. Every item carries its provider ref, so the list can never + * navigate into a different provider's repo. Read-only — no ledger. + */ + listInbox: baseProcedure.input(ListInboxInput).query(async ({ ctx, input }) => { + if (input.platform === 'gitlab') { + const owner = await gitlabOwner(ctx, input); + return providerCall(() => gitlabRead.listInbox(owner, input.cursor, input.instanceHint)); + } + const owner = await bitbucketOwner(ctx, input); + return providerCall(() => bitbucketRead.listInbox(owner, input.cursor)); + }), + + /** + * The provider-correct capability list. GitLab answers with the MR list + * (no `request_changes` event — the provider has none), Bitbucket with the + * shared s1 constant (auto-merge and reactions carry their reason strings). + */ + getCapabilities: baseProcedure.input(GetCapabilitiesInput).query(async ({ ctx, input }) => { + if (input.platform === 'gitlab') { + await gitlabOwner(ctx, input); + return GITLAB_MR_REVIEW_CAPABILITIES; + } + await bitbucketOwner(ctx, input); + return BITBUCKET_PR_REVIEW_CAPABILITIES; + }), + + getMergeState: baseProcedure.input(GetMergeStateInput).query(async ({ ctx, input }) => { + if (input.platform === 'gitlab') { + const owner = await gitlabOwner(ctx, input); + return providerCall(() => + gitlabRead.getMergeState(owner, input.projectPath, input.mrIid, input.instanceHint) + ); + } + const owner = await bitbucketOwner(ctx, input); + return providerCall(() => + bitbucketRead.getMergeRestrictions(owner, input.workspace, input.repoSlug, input.prId) + ); + }), + + /** Post a comment. With an `anchor` it becomes a real inline discussion. */ + addComment: baseProcedure.input(AddCommentInput).mutation(async ({ ctx, input }) => { + await assertTermsAccepted(ctx.user.id); + const anchorFields = { + path: input.anchor?.path, + line: input.anchor?.line, + side: input.anchor?.side, + startLine: input.anchor?.startLine, + }; + if (input.platform === 'gitlab') { + const owner = await gitlabOwner(ctx, input); + return runProviderMutation({ + ctx, + ref: providerRef(input), + intent: 'create_review_comment', + fingerprintInput: gitlabFingerprintInput(input, { + body: input.body, + ...anchorFields, + }), + operationKey: input.operationKey, + write: () => + gitlabAddComment({ + owner, + projectPath: input.projectPath, + mrIid: input.mrIid, + instanceHint: input.instanceHint, + body: input.body, + ...(input.anchor ? { anchor: input.anchor } : {}), + }), + reconcileAmbiguous: true, + }); + } + const owner = await bitbucketOwner(ctx, input); + return runProviderMutation({ + ctx, + ref: providerRef(input), + intent: 'create_review_comment', + fingerprintInput: bitbucketFingerprintInput(input, { + body: input.body, + ...anchorFields, + }), + operationKey: input.operationKey, + write: () => + bitbucketAddComment({ + owner, + workspace: input.workspace, + repoSlug: input.repoSlug, + prId: input.prId, + body: input.body, + ...(input.anchor ? { anchor: input.anchor } : {}), + }), + reconcileAmbiguous: true, + }); + }), + + /** Reply inside an existing thread (GitLab discussion / Bitbucket comment). */ + replyToComment: baseProcedure.input(ReplyToCommentInput).mutation(async ({ ctx, input }) => { + await assertTermsAccepted(ctx.user.id); + if (input.platform === 'gitlab') { + const owner = await gitlabOwner(ctx, input); + return runProviderMutation({ + ctx, + ref: providerRef(input), + intent: 'reply_comment', + fingerprintInput: gitlabFingerprintInput(input, { + commentId: input.discussionId, + body: input.body, + }), + operationKey: input.operationKey, + write: () => + gitlabReplyToComment({ + owner, + projectPath: input.projectPath, + mrIid: input.mrIid, + instanceHint: input.instanceHint, + discussionId: input.discussionId, + body: input.body, + }), + reconcileAmbiguous: true, + }); + } + const owner = await bitbucketOwner(ctx, input); + return runProviderMutation({ + ctx, + ref: providerRef(input), + intent: 'reply_comment', + fingerprintInput: bitbucketFingerprintInput(input, { + commentId: input.commentId, + body: input.body, + }), + operationKey: input.operationKey, + write: () => + bitbucketReplyToComment({ + owner, + workspace: input.workspace, + repoSlug: input.repoSlug, + prId: input.prId, + commentId: input.commentId, + body: input.body, + }), + reconcileAmbiguous: true, + }); + }), + + /** + * Submit a review. GitLab has no request-changes event: the write layer + * refuses it with the exact reason (BAD_REQUEST), never a silent fallback. + * An optional `comments` batch lands as real inline discussions before the + * review state/summary note. + */ + submitReview: baseProcedure.input(SubmitReviewInput).mutation(async ({ ctx, input }) => { + await assertTermsAccepted(ctx.user.id); + if (input.platform === 'gitlab') { + const owner = await gitlabOwner(ctx, input); + return runProviderMutation({ + ctx, + ref: providerRef(input), + intent: 'submit_review', + fingerprintInput: gitlabFingerprintInput(input, { + event: input.event, + body: input.body, + comments: input.comments, + }), + operationKey: input.operationKey, + write: () => + gitlabSubmitReview({ + owner, + projectPath: input.projectPath, + mrIid: input.mrIid, + instanceHint: input.instanceHint, + event: input.event, + body: input.body, + ...(input.comments ? { comments: input.comments } : {}), + }), + reconcileAmbiguous: true, + }); + } + const owner = await bitbucketOwner(ctx, input); + return runProviderMutation({ + ctx, + ref: providerRef(input), + intent: 'submit_review', + fingerprintInput: bitbucketFingerprintInput(input, { + event: input.event, + body: input.body, + comments: input.comments, + }), + operationKey: input.operationKey, + write: () => + bitbucketSubmitReview({ + owner, + workspace: input.workspace, + repoSlug: input.repoSlug, + prId: input.prId, + event: input.event, + body: input.body, + ...(input.comments ? { comments: input.comments } : {}), + }), + reconcileAmbiguous: true, + }); + }), + + resolveThread: baseProcedure.input(ResolveThreadInput).mutation(async ({ ctx, input }) => { + if (input.platform === 'gitlab') { + const owner = await gitlabOwner(ctx, input); + return runProviderMutation({ + ctx, + ref: providerRef(input), + intent: 'resolve_thread', + fingerprintInput: gitlabFingerprintInput(input, { + threadId: input.discussionId, + }), + operationKey: input.operationKey, + write: () => + gitlabResolveThread({ + owner, + projectPath: input.projectPath, + mrIid: input.mrIid, + instanceHint: input.instanceHint, + discussionId: input.discussionId, + }), + // Resolving is idempotent at the provider layer (already-resolved + // reports `replayed`), so a same-key retry may re-execute safely. + reconcileAmbiguous: false, + }); + } + const owner = await bitbucketOwner(ctx, input); + return runProviderMutation({ + ctx, + ref: providerRef(input), + intent: 'resolve_thread', + fingerprintInput: bitbucketFingerprintInput(input, { + threadId: input.threadId, + }), + operationKey: input.operationKey, + write: () => + bitbucketResolveThread({ + owner, + workspace: input.workspace, + repoSlug: input.repoSlug, + prId: input.prId, + threadId: input.threadId, + }), + reconcileAmbiguous: false, + }); + }), + + unresolveThread: baseProcedure.input(ResolveThreadInput).mutation(async ({ ctx, input }) => { + if (input.platform === 'gitlab') { + const owner = await gitlabOwner(ctx, input); + return runProviderMutation({ + ctx, + ref: providerRef(input), + intent: 'unresolve_thread', + fingerprintInput: gitlabFingerprintInput(input, { + threadId: input.discussionId, + }), + operationKey: input.operationKey, + write: () => + gitlabUnresolveThread({ + owner, + projectPath: input.projectPath, + mrIid: input.mrIid, + instanceHint: input.instanceHint, + discussionId: input.discussionId, + }), + reconcileAmbiguous: false, + }); + } + const owner = await bitbucketOwner(ctx, input); + return runProviderMutation({ + ctx, + ref: providerRef(input), + intent: 'unresolve_thread', + fingerprintInput: bitbucketFingerprintInput(input, { + threadId: input.threadId, + }), + operationKey: input.operationKey, + write: () => + bitbucketUnresolveThread({ + owner, + workspace: input.workspace, + repoSlug: input.repoSlug, + prId: input.prId, + threadId: input.threadId, + }), + reconcileAmbiguous: false, + }); + }), + + /** + * Merge a PR/MR. `expectedHeadSha` is the optimistic-concurrency fence: the + * write layer re-fetches the authoritative head and refuses a moved head + * with the exact stale-head reason BEFORE any merge call, so a stale + * revision can never merge another commit. + */ + mergePullRequest: baseProcedure.input(MergePullRequestInput).mutation(async ({ ctx, input }) => { + const ref = providerRef(input); + const base: ProviderLedgerBase = { + userId: ctx.user.id, + distinctId: ctx.user.google_user_email ?? ctx.user.id, + intent: 'merge', + startedAt: Date.now(), + platform: input.platform, + }; + const mergeFields = { + expectedHeadSha: input.expectedHeadSha, + deleteBranch: input.deleteBranch, + commitMessage: input.commitMessage, + commitTitle: input.platform === 'gitlab' ? input.commitTitle : undefined, + squash: input.platform === 'gitlab' ? input.squash : undefined, + }; + const fingerprintInput = + input.platform === 'gitlab' + ? gitlabFingerprintInput(input, { + method: input.squash ? 'squash' : 'merge', + commitTitle: input.commitTitle, + commitMessage: input.commitMessage, + deleteBranch: input.deleteBranch, + expectedHeadSha: input.expectedHeadSha, + }) + : bitbucketFingerprintInput(input, { + method: 'merge', + commitMessage: input.commitMessage, + deleteBranch: input.deleteBranch, + expectedHeadSha: input.expectedHeadSha, + }); + + if (input.platform === 'gitlab') { + const owner = await gitlabOwner(ctx, input); + const write = () => + gitlabMerge({ + owner, + projectPath: input.projectPath, + mrIid: input.mrIid, + instanceHint: input.instanceHint, + expectedHeadSha: mergeFields.expectedHeadSha, + squash: mergeFields.squash, + shouldRemoveSourceBranch: mergeFields.deleteBranch, + commitTitle: mergeFields.commitTitle, + commitMessage: mergeFields.commitMessage, + }); + if (input.operationKey === undefined) { + return providerCall(write); + } + const execute = (row: OperationLedgerRow) => executeProviderWrite(base, row, write); + return runProviderLedgerMutation({ + ...base, + operationKey: input.operationKey, + resourceKey: providerLedgerResourceKey('merge', ref, fingerprintInput), + execute, + reconcile: row => + reconcileMergeProviderRow(base, row, { + expectedHeadSha: input.expectedHeadSha, + // The authoritative read runs through the SAME owner-bound + // authorization as the write — a client hint can never steer + // the reconcile to another instance or project. + readSummary: () => + gitlabRead.getMergeRequest(owner, input.projectPath, input.mrIid, input.instanceHint), + execute: write, + }), + }); + } + + const owner = await bitbucketOwner(ctx, input); + const write = () => + bitbucketMerge({ + owner, + workspace: input.workspace, + repoSlug: input.repoSlug, + prId: input.prId, + expectedHeadSha: mergeFields.expectedHeadSha, + closeSourceBranch: mergeFields.deleteBranch, + commitMessage: mergeFields.commitMessage, + }); + if (input.operationKey === undefined) { + return providerCall(write); + } + const execute = (row: OperationLedgerRow) => executeProviderWrite(base, row, write); + return runProviderLedgerMutation({ + ...base, + operationKey: input.operationKey, + resourceKey: providerLedgerResourceKey('merge', ref, fingerprintInput), + execute, + reconcile: row => + reconcileMergeProviderRow(base, row, { + expectedHeadSha: input.expectedHeadSha, + // Owner-bound authoritative read — same identity as the write. + readSummary: () => + bitbucketRead.getPullRequest(owner, input.workspace, input.repoSlug, input.prId), + execute: write, + }), + }); + }), + + /** + * Enable auto-merge. GitLab: merge-when-pipeline-succeeds, fenced on the + * REQUIRED `expectedHeadSha` the write layer sends as `sha`. Bitbucket Cloud + * exposes no auto-merge API: the procedure returns the capability reason + * (no effect, no ledger row) so the UI shows why instead of failing. + */ + enableAutoMerge: baseProcedure.input(EnableAutoMergeInput).mutation(async ({ ctx, input }) => { + if (input.platform === 'bitbucket') { + await bitbucketOwner(ctx, input); + return { + supported: false as const, + reason: BITBUCKET_AUTO_MERGE_UNSUPPORTED_REASON, + done: false, + replayed: false, + }; + } + const owner = await gitlabOwner(ctx, input); + return runProviderMutation({ + ctx, + ref: providerRef(input), + intent: 'enable_auto_merge', + fingerprintInput: gitlabFingerprintInput(input, { + expectedHeadSha: input.expectedHeadSha, + }), + operationKey: input.operationKey, + write: async () => { + const result = await gitlabEnableAutoMerge({ + owner, + projectPath: input.projectPath, + mrIid: input.mrIid, + instanceHint: input.instanceHint, + expectedHeadSha: input.expectedHeadSha, + }); + return { supported: true as const, reason: '', ...result }; + }, + reconcileAmbiguous: false, + }); + }), + + /** Disable auto-merge. Bitbucket returns the capability reason — see enableAutoMerge. */ + disableAutoMerge: baseProcedure.input(DisableAutoMergeInput).mutation(async ({ ctx, input }) => { + if (input.platform === 'bitbucket') { + await bitbucketOwner(ctx, input); + return { + supported: false as const, + reason: BITBUCKET_AUTO_MERGE_UNSUPPORTED_REASON, + done: false, + replayed: false, + }; + } + const owner = await gitlabOwner(ctx, input); + return runProviderMutation({ + ctx, + ref: providerRef(input), + intent: 'disable_auto_merge', + fingerprintInput: gitlabFingerprintInput(input, { + expectedHeadSha: input.expectedHeadSha, + }), + operationKey: input.operationKey, + write: async () => { + const result = await gitlabDisableAutoMerge({ + owner, + projectPath: input.projectPath, + mrIid: input.mrIid, + instanceHint: input.instanceHint, + expectedHeadSha: input.expectedHeadSha, + }); + return { supported: true as const, reason: '', ...result }; + }, + reconcileAmbiguous: false, + }); + }), +}); diff --git a/apps/web/src/routers/root-router.test.ts b/apps/web/src/routers/root-router.test.ts index 4c867ff62e..e1de8ceb32 100644 --- a/apps/web/src/routers/root-router.test.ts +++ b/apps/web/src/routers/root-router.test.ts @@ -36,6 +36,12 @@ describe('trpc tests', () => { }); describe('router composition', () => { + it('registers providerReview on the server root so mobile calls resolve', () => { + expect(rootRouter._def.record).toHaveProperty('providerReview'); + expect(rootRouter._def.record).toHaveProperty('providerReview.getPullRequest'); + expect(rootRouter._def.record).toHaveProperty('providerReview.addComment'); + }); + it('registers Bitbucket only under organizations', () => { expect(rootRouter._def.record).not.toHaveProperty('bitbucket'); expect(rootRouter._def.record).toHaveProperty('organizations.bitbucket'); diff --git a/apps/web/src/routers/root-router.ts b/apps/web/src/routers/root-router.ts index 72f7effd2a..a2486955ee 100644 --- a/apps/web/src/routers/root-router.ts +++ b/apps/web/src/routers/root-router.ts @@ -47,6 +47,7 @@ import { mcpGatewayRouter } from '@/routers/mcp-gateway-router'; import { mcpGatewayAuthorizationsRouter } from '@/routers/mcp-gateway-authorizations-router'; import { modelPreferencesRouter } from '@/routers/model-preferences-router'; import { githubPrReviewRouter } from '@/routers/github-pr-review-router'; +import { providerReviewRouter } from '@/routers/provider-review-router'; import { moderationRouter } from '@/routers/moderation-router'; import { userExportsRouter } from '@/routers/user-exports-router'; import { quickChatRouter } from '@/routers/quick-chat-router'; @@ -98,6 +99,7 @@ export const rootRouter = createTRPCRouter({ mcpGatewayAuthorizations: mcpGatewayAuthorizationsRouter, modelPreferences: modelPreferencesRouter, githubPrReview: githubPrReviewRouter, + providerReview: providerReviewRouter, moderation: moderationRouter, userExports: userExportsRouter, quickChat: quickChatRouter, diff --git a/packages/app-shared/package.json b/packages/app-shared/package.json index 62f58c7aec..ebc88019ae 100644 --- a/packages/app-shared/package.json +++ b/packages/app-shared/package.json @@ -18,6 +18,7 @@ "./analytics": "./src/analytics/index.ts", "./app-version": "./src/app-version.ts", "./pr-review": "./src/pr-review/index.ts", + "./provider-review": "./src/provider-review/index.ts", "./commerce": "./src/commerce/index.ts", "./moderation": "./src/moderation/index.ts", "./glanceable-agents-snapshot": "./src/glanceable-agents-snapshot.ts" diff --git a/packages/app-shared/src/analytics/event-map.ts b/packages/app-shared/src/analytics/event-map.ts index 31ed86da94..651f9e82b3 100644 --- a/packages/app-shared/src/analytics/event-map.ts +++ b/packages/app-shared/src/analytics/event-map.ts @@ -76,11 +76,18 @@ export const SESSION_CREATE_FAILURE_STAGES = [ 'initial_admission', ] as const; export const SESSION_CREATE_ADMISSIONS = ['new', 'takeover'] as const; +// The four provider-review intents beyond the GitHub set belong to the +// GitLab/Bitbucket operation ledger (provider-review-router.ts); the ledger +// taxonomy and the analytics vocabulary stay one list. export const PR_INTENTS = [ 'merge', 'submit_review', 'create_review_comment', 'reply_comment', + 'resolve_thread', + 'unresolve_thread', + 'enable_auto_merge', + 'disable_auto_merge', ] as const; export const SECURITY_INTENTS = [ 'manual_sync', diff --git a/packages/app-shared/src/pr-review/intent-fingerprint.test.ts b/packages/app-shared/src/pr-review/intent-fingerprint.test.ts index 30f1c72775..00160d3cc2 100644 --- a/packages/app-shared/src/pr-review/intent-fingerprint.test.ts +++ b/packages/app-shared/src/pr-review/intent-fingerprint.test.ts @@ -100,4 +100,100 @@ describe('prIntentFingerprint', () => { ).not.toBe(original); expect(prIntentFingerprint('merge', { ...MERGE_INPUT, commitTitle: 'T' })).not.toBe(original); }); + + // The provider split adds `gitlab`/`bitbucket` resource shapes WITHOUT + // touching the GitHub bytes: an absent or 'github' platform must keep the + // exact legacy `[owner, repo, number]` resource, or every in-flight + // GitHub key would rotate and break the ledger's dedupe window. + it('keeps GitHub fingerprints byte-identical with an absent or explicit github platform', () => { + expect(prIntentFingerprint('create_review_comment', COMMENT_INPUT)).toBe( + '{"resource":["octocat","hello",1],"body":"inline nit","path":"README.md","line":3,"side":"RIGHT","commitSha":"' + + SHA + + '"}' + ); + for (const [intent, input] of [ + ['create_review_comment', COMMENT_INPUT], + ['submit_review', REVIEW_INPUT], + ['merge', MERGE_INPUT], + ['reply_comment', REPLY_INPUT], + ] as const) { + const legacy = prIntentFingerprint(intent, input); + expect(prIntentFingerprint(intent, { ...input, platform: 'github' })).toBe(legacy); + } + }); + + it('pins the gitlab resource bytes', () => { + expect( + prIntentFingerprint('merge', { + platform: 'gitlab', + projectPath: 'group/sub/repo', + instanceHint: 'gitlab.example.com', + number: 42, + method: 'squash', + deleteBranch: true, + expectedHeadSha: SHA, + }) + ).toBe( + `{"resource":["gitlab","gitlab.example.com","group/sub/repo",42],"method":"squash","deleteBranch":true,"expectedHeadSha":"${SHA}"}` + ); + expect( + prIntentFingerprint('merge', { + platform: 'gitlab', + projectPath: 'group/sub/repo', + number: 42, + method: 'squash', + deleteBranch: true, + expectedHeadSha: SHA, + }) + ).toBe( + `{"resource":["gitlab","","group/sub/repo",42],"method":"squash","deleteBranch":true,"expectedHeadSha":"${SHA}"}` + ); + }); + + it('pins the bitbucket resource bytes', () => { + expect( + prIntentFingerprint('submit_review', { + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'api', + number: 7, + event: 'APPROVE', + body: 'LGTM', + commitSha: SHA, + comments: [], + }) + ).toBe( + '{"resource":["bitbucket","acme","api",7],"event":"APPROVE","body":"LGTM","commitSha":"' + + SHA + + '","comments":[]}' + ); + }); + + it('never collides across providers for same-named resources', () => { + const github = prIntentFingerprint('merge', MERGE_INPUT); + const gitlabInput = { + platform: 'gitlab', + projectPath: 'octocat/hello', + number: 1, + method: 'squash', + deleteBranch: true, + expectedHeadSha: SHA, + }; + const bitbucket = prIntentFingerprint('merge', { + platform: 'bitbucket', + workspace: 'octocat', + repoSlug: 'hello', + number: 1, + method: 'squash', + deleteBranch: true, + expectedHeadSha: SHA, + }); + const gitlabNoHint = prIntentFingerprint('merge', gitlabInput); + const gitlabSelfHosted = prIntentFingerprint('merge', { + ...gitlabInput, + instanceHint: 'gitlab.example.com', + }); + const gitlabSaaS = prIntentFingerprint('merge', { ...gitlabInput, instanceHint: 'gitlab.com' }); + expect(new Set([github, gitlabNoHint, gitlabSelfHosted, gitlabSaaS, bitbucket]).size).toBe(5); + }); }); diff --git a/packages/app-shared/src/pr-review/intent-fingerprint.ts b/packages/app-shared/src/pr-review/intent-fingerprint.ts index 6f11a3d74d..948576e567 100644 --- a/packages/app-shared/src/pr-review/intent-fingerprint.ts +++ b/packages/app-shared/src/pr-review/intent-fingerprint.ts @@ -7,35 +7,79 @@ * ledger's 30-day retention window, so a drift between the two rotates every * in-flight key and makes same-key retries fail with * `operation_key_reuse_mismatch`. + * + * The `resource` part is provider-split so same-named repos on different + * providers can never share a retry key: + * - absent or `'github'` platform: `[owner, repo, number]` — the legacy + * bytes, pinned byte-identical so no in-flight GitHub key ever rotates; + * - `'gitlab'`: `['gitlab', instanceHint ?? '', projectPath, number]`; + * - `'bitbucket'`: `['bitbucket', workspace, repoSlug, number]`. */ -export type PrLedgerIntent = 'merge' | 'submit_review' | 'create_review_comment' | 'reply_comment'; +import type { ProviderPrPlatform } from '../provider-review/contracts'; + +export type PrLedgerIntent = + | 'merge' + | 'submit_review' + | 'create_review_comment' + | 'reply_comment' + | 'resolve_thread' + | 'unresolve_thread' + | 'enable_auto_merge' + | 'disable_auto_merge'; /** * The intent inputs folded into the ledger fingerprint. Any change to one * (comment body, review contents, merge method, fence sha, …) yields a * different fingerprint, so a key reused for a different request is rejected * instead of replaying the old canonical result. Field ORDER is part of the - * hash — do not reorder. + * hash — do not reorder. The four thread/auto-merge intents serve the + * provider review router (GitLab/Bitbucket); the GitHub router never uses + * them, and the four legacy field lists are byte-frozen. */ const PR_FINGERPRINT_FIELDS: Record = { create_review_comment: ['body', 'path', 'line', 'side', 'startLine', 'startSide', 'commitSha'], reply_comment: ['commentId', 'body'], submit_review: ['event', 'body', 'commitSha', 'comments'], merge: ['method', 'commitTitle', 'commitMessage', 'deleteBranch', 'expectedHeadSha'], + resolve_thread: ['threadId'], + unresolve_thread: ['threadId'], + enable_auto_merge: ['expectedHeadSha'], + disable_auto_merge: ['expectedHeadSha'], }; /** - * The deterministic fingerprint of one PR intent: the `owner/repo/number` - * resource plus the intent-defining fields, in the fixed field order. - * `JSON.stringify` follows insertion order, so the field list is what keeps - * the bytes stable across callers that build the input in any order. + * The provider-safe `resource` part of the fingerprint. Field ORDER is part + * of the hash — do not reorder. The `'gitlab'` / `'bitbucket'` tags are + * literal array elements, so a GitHub resource can never serialize to the + * same bytes as a GitLab or Bitbucket one. + */ +function fingerprintResource(input: Record): readonly unknown[] { + const platform = input.platform as ProviderPrPlatform | undefined; + switch (platform) { + case 'gitlab': + return ['gitlab', input.instanceHint ?? '', input.projectPath, input.number]; + case 'bitbucket': + return ['bitbucket', input.workspace, input.repoSlug, input.number]; + default: + // Absent or 'github': the legacy [owner, repo, number] bytes. Changing + // these rotates every in-flight GitHub key — do not touch. + return [input.owner, input.repo, input.number]; + } +} + +/** + * The deterministic fingerprint of one PR intent: the provider-split + * resource (see `fingerprintResource`) plus the intent-defining fields, in + * the fixed field order. `JSON.stringify` follows insertion order, so the + * field list is what keeps the bytes stable across callers that build the + * input in any order. */ export function prIntentFingerprint( intent: PrLedgerIntent, input: Record ): string { - const parts: Record = { resource: [input.owner, input.repo, input.number] }; + const parts: Record = { resource: fingerprintResource(input) }; for (const field of PR_FINGERPRINT_FIELDS[intent]) { parts[field] = input[field]; } diff --git a/packages/app-shared/src/provider-review/capabilities.test.ts b/packages/app-shared/src/provider-review/capabilities.test.ts new file mode 100644 index 0000000000..8083ffb563 --- /dev/null +++ b/packages/app-shared/src/provider-review/capabilities.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { + PROVIDER_REVIEW_CAPABILITIES, + providerPrTerm, + type ProviderReviewCapabilities, +} from './capabilities'; + +const PLATFORMS = ['github', 'gitlab', 'bitbucket'] as const; + +function unsupported(capabilities: ProviderReviewCapabilities) { + return [capabilities.autoMerge, capabilities.reactions, capabilities.reviewStatus].filter( + capability => !capability.supported + ); +} + +describe('PROVIDER_REVIEW_CAPABILITIES', () => { + it('gives every unsupported capability a human-readable provider reason', () => { + for (const platform of PLATFORMS) { + const capabilities = PROVIDER_REVIEW_CAPABILITIES[platform]; + for (const capability of unsupported(capabilities)) { + expect(capability.reason.length).toBeGreaterThan(0); + expect(capability.reason[0]).toBe(capability.reason[0]?.toUpperCase()); + } + } + // The example from the requirement: Bitbucket Cloud auto-merge. + expect(PROVIDER_REVIEW_CAPABILITIES.bitbucket.autoMerge.supported).toBe(false); + expect(PROVIDER_REVIEW_CAPABILITIES.bitbucket.autoMerge.reason).toBe( + 'Bitbucket Cloud does not expose auto-merge in its API' + ); + expect(PROVIDER_REVIEW_CAPABILITIES.bitbucket.reactions.supported).toBe(false); + expect(PROVIDER_REVIEW_CAPABILITIES.bitbucket.reactions.reason.length).toBeGreaterThan(0); + }); + + it('keeps supported capabilities free of a reason string', () => { + for (const platform of PLATFORMS) { + const capabilities = PROVIDER_REVIEW_CAPABILITIES[platform]; + for (const capability of [ + capabilities.autoMerge, + capabilities.reactions, + capabilities.reviewStatus, + ]) { + if (capability.supported) expect(capability.reason).toBe(''); + } + } + }); + + it('lists only the review events the contract allows', () => { + for (const platform of PLATFORMS) { + for (const event of PROVIDER_REVIEW_CAPABILITIES[platform].reviewEvents) { + expect(['approve', 'request_changes', 'comment']).toContain(event); + } + } + }); +}); + +describe('providerPrTerm', () => { + it('calls a GitLab review object a merge request and the others pull requests', () => { + expect(providerPrTerm('gitlab')).toBe('merge request'); + expect(providerPrTerm('github')).toBe('pull request'); + expect(providerPrTerm('bitbucket')).toBe('pull request'); + }); +}); diff --git a/packages/app-shared/src/provider-review/capabilities.ts b/packages/app-shared/src/provider-review/capabilities.ts new file mode 100644 index 0000000000..f5c0d0ae3c --- /dev/null +++ b/packages/app-shared/src/provider-review/capabilities.ts @@ -0,0 +1,94 @@ +/** + * The explicit capability vocabulary for provider review surfaces. + * + * The mobile presentation renders affordances from these flags instead of + * probing provider endpoints, so an unsupported action degrades to a + * reason string the UI can show — never to a silent failure. + * + * Invariant: every `supported: false` capability carries a human-readable + * provider reason. The per-provider constants below are the single source + * of that copy. + */ + +import type { ProviderPrPlatform } from './contracts'; + +/** The review events a provider lets a reviewer submit. */ +export type ProviderReviewEvent = 'approve' | 'request_changes' | 'comment'; + +/** + * A capability that may be absent on a provider. When `supported` is false, + * `reason` MUST hold a human-readable explanation naming the provider + * (e.g. 'Bitbucket Cloud does not expose auto-merge in its API'); when + * supported, `reason` is ''. + */ +export type ProviderReviewCapability = { + supported: boolean; + reason: string; +}; + +export type ProviderReviewCapabilities = { + /** Whether the viewer can post a comment on the PR/MR. */ + canComment: boolean; + /** The review events the provider accepts, in display order. */ + reviewEvents: ProviderReviewEvent[]; + canResolveThreads: boolean; + canMerge: boolean; + autoMerge: ProviderReviewCapability; + reactions: ProviderReviewCapability; + /** Whether the provider exposes per-reviewer approval states. */ + reviewStatus: ProviderReviewCapability; +}; + +const SUPPORTED: ProviderReviewCapability = { supported: true, reason: '' }; + +export const GITHUB_REVIEW_CAPABILITIES: ProviderReviewCapabilities = { + canComment: true, + reviewEvents: ['approve', 'request_changes', 'comment'], + canResolveThreads: true, + canMerge: true, + autoMerge: SUPPORTED, + reactions: SUPPORTED, + reviewStatus: SUPPORTED, +}; + +export const GITLAB_REVIEW_CAPABILITIES: ProviderReviewCapabilities = { + canComment: true, + reviewEvents: ['approve', 'request_changes', 'comment'], + canResolveThreads: true, + canMerge: true, + autoMerge: SUPPORTED, + reactions: SUPPORTED, + reviewStatus: SUPPORTED, +}; + +export const BITBUCKET_REVIEW_CAPABILITIES: ProviderReviewCapabilities = { + canComment: true, + reviewEvents: ['approve', 'request_changes', 'comment'], + canResolveThreads: true, + canMerge: true, + autoMerge: { + supported: false, + reason: 'Bitbucket Cloud does not expose auto-merge in its API', + }, + reactions: { + supported: false, + reason: 'Bitbucket Cloud does not expose reactions on pull request comments', + }, + reviewStatus: SUPPORTED, +}; + +export const PROVIDER_REVIEW_CAPABILITIES: Record = + { + github: GITHUB_REVIEW_CAPABILITIES, + gitlab: GITLAB_REVIEW_CAPABILITIES, + bitbucket: BITBUCKET_REVIEW_CAPABILITIES, + }; + +/** + * The provider-correct term for a code-review object: GitLab calls it a + * merge request, GitHub and Bitbucket a pull request. User-facing copy MUST + * build its nouns from this so the wording matches the connected provider. + */ +export function providerPrTerm(platform: ProviderPrPlatform): 'pull request' | 'merge request' { + return platform === 'gitlab' ? 'merge request' : 'pull request'; +} diff --git a/packages/app-shared/src/provider-review/contracts.test.ts b/packages/app-shared/src/provider-review/contracts.test.ts new file mode 100644 index 0000000000..80033db96b --- /dev/null +++ b/packages/app-shared/src/provider-review/contracts.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import { + gitlabInstanceOrigin, + providerPrRefKey, + type ProviderPrInboxItem, + type ProviderPrRef, +} from './contracts'; + +const GITHUB: ProviderPrRef = { platform: 'github', owner: 'acme', repo: 'api', number: 7 }; +const GITLAB: ProviderPrRef = { + platform: 'gitlab', + projectPath: 'acme/api', + mrIid: 7, + instanceHint: 'gitlab.com', +}; +const BITBUCKET: ProviderPrRef = { + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'api', + prId: 7, +}; + +describe('providerPrRefKey', () => { + it('is deterministic and canonical for the same ref', () => { + expect(providerPrRefKey(GITHUB)).toBe(providerPrRefKey({ ...GITHUB })); + expect(providerPrRefKey(GITLAB)).toBe(providerPrRefKey({ ...GITLAB })); + expect(providerPrRefKey(BITBUCKET)).toBe(providerPrRefKey({ ...BITBUCKET })); + }); + + // The same repo name on three providers is exactly the collision the key + // must prevent: identity is carried everywhere. + it('never collides across providers for same-named repositories', () => { + expect( + new Set([providerPrRefKey(GITHUB), providerPrRefKey(GITLAB), providerPrRefKey(BITBUCKET)]) + .size + ).toBe(3); + }); + + it('never collides across GitLab instances or against a missing hint', () => { + const saas = providerPrRefKey(GITLAB); + const selfHosted = providerPrRefKey({ ...GITLAB, instanceHint: 'gitlab.example.com' }); + const noHint = providerPrRefKey({ platform: 'gitlab', projectPath: 'acme/api', mrIid: 7 }); + expect(new Set([saas, selfHosted, noHint]).size).toBe(3); + // A missing hint is its own bucket, never equal to the SaaS host. + expect(noHint).not.toBe(saas); + }); + + it('folds a GitLab instanceHint URL to its origin', () => { + const selfHosted = providerPrRefKey({ ...GITLAB, instanceHint: 'gitlab.example.com' }); + expect( + providerPrRefKey({ ...GITLAB, instanceHint: 'https://GitLab.example.com/acme/api' }) + ).toBe(selfHosted); + // A different port is a different instance. + expect(providerPrRefKey({ ...GITLAB, instanceHint: 'gitlab.example.com:8443' })).not.toBe( + selfHosted + ); + }); + + it('keeps nested GitLab project paths unambiguous', () => { + // A path segment can never bleed into the instance or the iid: JSON + // escaping keeps array elements apart. + expect(providerPrRefKey({ ...GITLAB, projectPath: 'group/sub/repo' })).not.toBe( + providerPrRefKey({ ...GITLAB, projectPath: 'group' }) + ); + expect( + providerPrRefKey({ + platform: 'gitlab', + projectPath: 'a","b', + mrIid: 1, + instanceHint: 'x', + }) + ).not.toBe( + providerPrRefKey({ + platform: 'gitlab', + projectPath: 'b', + mrIid: 1, + instanceHint: `x","a`, + }) + ); + }); + + it('folds Bitbucket workspace and repository identity apart', () => { + const otherWorkspace = providerPrRefKey({ ...BITBUCKET, workspace: 'other' }); + const otherRepo = providerPrRefKey({ ...BITBUCKET, repoSlug: 'web' }); + expect(new Set([providerPrRefKey(BITBUCKET), otherWorkspace, otherRepo]).size).toBe(3); + }); + + it('separates pull request numbers', () => { + expect(providerPrRefKey({ ...GITHUB, number: 8 })).not.toBe(providerPrRefKey(GITHUB)); + expect(providerPrRefKey({ ...GITLAB, mrIid: 8 })).not.toBe(providerPrRefKey(GITLAB)); + expect(providerPrRefKey({ ...BITBUCKET, prId: 8 })).not.toBe(providerPrRefKey(BITBUCKET)); + }); +}); + +describe('gitlabInstanceOrigin', () => { + it('normalizes scheme, case, path, and query but keeps the port', () => { + expect(gitlabInstanceOrigin()).toBe(''); + expect(gitlabInstanceOrigin(' ')).toBe(''); + expect(gitlabInstanceOrigin('GitLab.Example.com')).toBe('gitlab.example.com'); + expect(gitlabInstanceOrigin('https://gitlab.example.com/group/repo')).toBe( + 'gitlab.example.com' + ); + expect(gitlabInstanceOrigin('gitlab.example.com:8443')).toBe('gitlab.example.com:8443'); + expect(gitlabInstanceOrigin('gitlab.example.com/?x=1')).toBe('gitlab.example.com'); + }); +}); + +describe('contract shapes', () => { + // The inbox row must always carry its ref: the type below only compiles + // because `ref` is required on ProviderPrInboxItem. + it('carries the ref on every inbox item', () => { + const item: ProviderPrInboxItem = { + ref: GITLAB, + title: 'Add retry', + author: { login: 'octocat', avatarUrl: null }, + state: 'open', + draft: false, + updatedAt: '2026-09-06T00:00:00Z', + }; + expect(providerPrRefKey(item.ref)).toBe(providerPrRefKey(GITLAB)); + }); +}); diff --git a/packages/app-shared/src/provider-review/contracts.ts b/packages/app-shared/src/provider-review/contracts.ts new file mode 100644 index 0000000000..5f9e523635 --- /dev/null +++ b/packages/app-shared/src/provider-review/contracts.ts @@ -0,0 +1,263 @@ +/** + * Provider-discriminated identity and DTO contracts for the PR/MR review + * layer (GitHub, GitLab, Bitbucket). + * + * This module is pure vocabulary: types plus one canonical key function. + * No behavior, no I/O. The server mappers (s2–s4), the router, and the + * mobile presentation all derive their shapes from here, so a provider + * difference never leaks past its mapper. + */ + +export type ProviderPrPlatform = 'github' | 'gitlab' | 'bitbucket'; + +/** A GitHub pull request: the `owner/repo#number` triple the mobile tree already routes on. */ +export type GitHubPrRef = { + platform: 'github'; + owner: string; + repo: string; + number: number; +}; + +/** + * A GitLab merge request. `projectPath` is the FULL nested path, e.g. + * `group/sub/repo` — never just the last segment. `instanceHint` identifies + * which GitLab instance the user connected; it is display/matching only and + * MUST never be used as an API base. + */ +export type GitLabMrRef = { + platform: 'gitlab'; + projectPath: string; + mrIid: number; + instanceHint?: string; +}; + +/** A Bitbucket Cloud pull request: `workspace/repoSlug` plus the numeric `prId`. */ +export type BitbucketPrRef = { + platform: 'bitbucket'; + workspace: string; + repoSlug: string; + prId: number; +}; + +export type ProviderPrRef = GitHubPrRef | GitLabMrRef | BitbucketPrRef; + +/** + * The canonical cache/draft/recents key for one ref. + * + * The key folds the platform tag, the GitLab instance origin, and the + * Bitbucket workspace/repository identity into a JSON array, so two + * same-named repositories on different providers (or on different GitLab + * instances) can never collide: JSON escaping keeps each array element + * unambiguous, and the leading platform tag keeps the namespaces apart. + * + * Identity fields are used as the provider returned them. Only the instance + * origin is normalized (hostnames are case-insensitive per DNS); repository + * paths are NOT case-folded, because a self-managed instance may treat two + * casings as distinct and a miss is safe while a collision is not. + */ +export function providerPrRefKey(ref: ProviderPrRef): string { + switch (ref.platform) { + case 'github': + return JSON.stringify(['github', ref.owner, ref.repo, ref.number]); + case 'gitlab': + return JSON.stringify([ + 'gitlab', + gitlabInstanceOrigin(ref.instanceHint), + ref.projectPath, + ref.mrIid, + ]); + case 'bitbucket': + return JSON.stringify(['bitbucket', ref.workspace, ref.repoSlug, ref.prId]); + } +} + +/** + * The normalized origin of a GitLab `instanceHint` for identity folding: + * scheme, path, and query are dropped, the host is lowercased, and the port + * is kept (an instance on another port is another instance). An absent hint + * folds to `''` — deliberately NOT equal to `'gitlab.com'`, so a ref without + * a hint can never collide with one pinned to the SaaS host. + */ +export function gitlabInstanceOrigin(instanceHint?: string): string { + if (!instanceHint) return ''; + let rest = instanceHint.trim().toLowerCase(); + const scheme = rest.match(/^[a-z][a-z0-9+.-]*:\/\//); + if (scheme) rest = rest.slice(scheme[0].length); + return (rest.split('/')[0] ?? '').split('?')[0] ?? ''; +} + +/** An author or reviewer identity. `login` is the provider username. */ +export type ProviderPrAuthor = { + login: string; + avatarUrl: string | null; +}; + +/** The lifecycle state every provider maps onto. */ +export type ProviderPrState = 'open' | 'closed' | 'merged'; + +/** Which side of a diff a comment or thread anchors to. */ +export type ProviderPrDiffSide = 'LEFT' | 'RIGHT'; + +/** + * The diff position one inline review comment anchors to. `line` is the + * anchor line on `side`; `startLine` marks the first line of a multi-line + * range (GitHub parity: GitLab diff discussions and Bitbucket inline + * comments both accept this shape). + */ +export type ProviderReviewInlineAnchor = { + path: string; + side: ProviderPrDiffSide; + line: number; + startLine?: number; +}; + +/** One inline comment inside a review submission batch. */ +export type ProviderReviewInlineComment = ProviderReviewInlineAnchor & { + body: string; +}; + +/** + * One PR/MR as the review screen renders it. Field shapes mirror what the + * mobile tree consumes today from `githubPrReview` (title, author, state, + * head/target refs, headSha, changedFiles, additions, deletions, body, + * webUrl); the ref is always carried so every surface keys by provider. + */ +export type ProviderPrSummary = { + ref: ProviderPrRef; + title: string; + /** Markdown body, or null when the provider has none. */ + body: string | null; + author: ProviderPrAuthor | null; + state: ProviderPrState; + draft: boolean; + /** The source branch (head) the change comes from. */ + headRef: string; + /** The target branch (base) the change merges into. */ + baseRef: string; + /** The head commit sha — the fence every write intent compares against. */ + headSha: string; + changedFiles: number; + additions: number; + deletions: number; + /** The provider's canonical web URL for this PR/MR. */ + webUrl: string; + createdAt: string; + updatedAt: string; +}; + +/** One changed file in the files page. `patch` is null when the provider omits it. */ +export type ProviderPrFile = { + path: string; + previousPath: string | null; + status: string; + additions: number; + deletions: number; + patch: string | null; + patchMissing: boolean; +}; + +/** + * One page of changed files. `nextCursor` is an opaque provider string + * (GitLab pages tokens, Bitbucket page params, GitHub cursors all fold in); + * null means the last page. + */ +export type ProviderPrFilesPage = { + files: ProviderPrFile[]; + nextCursor: string | null; +}; + +/** One comment in a discussion thread. `commentId` is a string because provider ids are not all numeric. */ +export type ProviderPrComment = { + commentId: string; + author: ProviderPrAuthor | null; + body: string; + createdAt: string; +}; + +/** + * One inline discussion thread. Anchors (`path`, `line`, `side`) are null for + * threads the provider does not pin to a diff position. + */ +export type ProviderPrThread = { + threadId: string; + resolved: boolean; + path: string | null; + line: number | null; + side: ProviderPrDiffSide | null; + comments: ProviderPrComment[]; +}; + +/** One page of discussion threads. */ +export type ProviderPrThreadsPage = { + threads: ProviderPrThread[]; + nextCursor: string | null; +}; + +/** + * One CI check on the PR/MR. `status` is the provider's run state and + * `conclusion` its final verdict — both kept as strings because every + * provider has its own vocabulary the mapper passes through. + */ +export type ProviderPrCheck = { + name: string; + status: string; + conclusion: string | null; + detailsUrl: string | null; +}; + +/** The checks rollup for one PR/MR. */ +export type ProviderPrChecksResult = { + checks: ProviderPrCheck[]; +}; + +/** + * Why a merge is blocked right now. `code` is the stable machine id the + * presentation picks an icon and a layout from; `message` is the + * human-readable provider text. + */ +export type ProviderPrMergeBlockedReason = { + code: + | 'conflicts' + | 'required_approvals' + | 'failing_pipeline' + | 'pending_pipeline' + | 'draft' + | 'permission' + | 'other'; + message: string; +}; + +/** + * The merge gate for one PR/MR: the branch policy (`approvalsRequired`, + * `pipelineMustSucceed`), the conflict flag, and the concrete list of what + * blocks merging right now. + */ +export type ProviderPrMergeState = { + canMerge: boolean; + /** How many approvals the policy requires; 0 when the provider has no approval gate. */ + approvalsRequired: number; + /** Whether a succeeding pipeline is required before merging. */ + pipelineMustSucceed: boolean; + conflicts: boolean; + blockedReasons: ProviderPrMergeBlockedReason[]; +}; + +/** + * One inbox row. It ALWAYS carries its `ProviderPrRef`, so the list, the + * cache key, and the navigation target can never disagree about which + * provider's repo the row points at. + */ +export type ProviderPrInboxItem = { + ref: ProviderPrRef; + title: string; + author: ProviderPrAuthor | null; + state: ProviderPrState; + draft: boolean; + updatedAt: string; +}; + +/** One page of inbox rows. */ +export type ProviderPrInboxPage = { + items: ProviderPrInboxItem[]; + nextCursor: string | null; +}; diff --git a/packages/app-shared/src/provider-review/index.ts b/packages/app-shared/src/provider-review/index.ts new file mode 100644 index 0000000000..5d28b6fa64 --- /dev/null +++ b/packages/app-shared/src/provider-review/index.ts @@ -0,0 +1,2 @@ +export * from './capabilities'; +export * from './contracts'; diff --git a/packages/trpc/src/mobile.ts b/packages/trpc/src/mobile.ts index 1e4b38a503..e203b26225 100644 --- a/packages/trpc/src/mobile.ts +++ b/packages/trpc/src/mobile.ts @@ -15,6 +15,7 @@ import { modelsRouter } from '@/routers/models-router'; import { activeSessionsRouter } from '@/routers/active-sessions-router'; import { modelPreferencesRouter } from '@/routers/model-preferences-router'; import { githubPrReviewRouter } from '@/routers/github-pr-review-router'; +import { providerReviewRouter } from '@/routers/provider-review-router'; import { moderationRouter } from '@/routers/moderation-router'; import { kiloChatRouter } from '@/routers/kilo-chat-router'; import { quickChatRouter } from '@/routers/quick-chat-router'; @@ -23,8 +24,9 @@ import { agentProfilesMobileRouter } from './agent-profiles-mobile'; /** * Mobile-scoped tRPC router. Composes only the namespaces the mobile app * consumes, so `@kilocode/trpc/mobile` ships a smaller client-facing type - * surface than the full `RootRouter`. This is additive: `root-router.ts` is - * unchanged and remains the single source of the server router composition. + * surface than the full `RootRouter`. `root-router.ts` remains the single + * source of the server router composition: every namespace listed here must + * also be mounted there, or the call fails at runtime. */ const mobileRouter = createTRPCRouter({ organizations: organizationsRouter, @@ -42,6 +44,7 @@ const mobileRouter = createTRPCRouter({ activeSessions: activeSessionsRouter, modelPreferences: modelPreferencesRouter, githubPrReview: githubPrReviewRouter, + providerReview: providerReviewRouter, moderation: moderationRouter, kiloChat: kiloChatRouter, quickChat: quickChatRouter, diff --git a/packages/worker-utils/src/internal-service-token-audiences.test.ts b/packages/worker-utils/src/internal-service-token-audiences.test.ts index 934d718684..5539b695f8 100644 --- a/packages/worker-utils/src/internal-service-token-audiences.test.ts +++ b/packages/worker-utils/src/internal-service-token-audiences.test.ts @@ -5,6 +5,7 @@ import { BITBUCKET_CODE_REVIEW_WEBHOOK_DELETE_AUDIENCE, BITBUCKET_CODE_REVIEW_WEBHOOK_ENSURE_AUDIENCE, BITBUCKET_REPOSITORY_LIST_AUDIENCE, + BITBUCKET_WORKSPACE_ACCESS_TOKEN_AUDIENCE, GITLAB_CREDENTIAL_BROKER_AUDIENCE, SESSION_INGEST_USER_DELETION_AUDIENCE, } from './internal-service-token-audiences.js'; @@ -13,6 +14,7 @@ describe('internal service token audiences', () => { it('keeps Bitbucket operations purpose-bound and mutually distinct', () => { const audiences = [ BITBUCKET_REPOSITORY_LIST_AUDIENCE, + BITBUCKET_WORKSPACE_ACCESS_TOKEN_AUDIENCE, BITBUCKET_CODE_REVIEW_PULL_REQUEST_AUDIENCE, BITBUCKET_CODE_REVIEW_WEBHOOK_ENSURE_AUDIENCE, BITBUCKET_CODE_REVIEW_WEBHOOK_DELETE_AUDIENCE, @@ -21,6 +23,8 @@ describe('internal service token audiences', () => { expect(new Set(audiences).size).toBe(audiences.length); expect(audiences).toEqual( expect.arrayContaining([ + 'git-token-service:bitbucket-repositories', + 'git-token-service:bitbucket-workspace-access-token', 'git-token-service:bitbucket-code-review:pull-request', 'git-token-service:bitbucket-code-review:webhook-ensure', 'git-token-service:bitbucket-code-review:webhook-delete', diff --git a/packages/worker-utils/src/internal-service-token-audiences.ts b/packages/worker-utils/src/internal-service-token-audiences.ts index b1725ae73d..d6300465d2 100644 --- a/packages/worker-utils/src/internal-service-token-audiences.ts +++ b/packages/worker-utils/src/internal-service-token-audiences.ts @@ -12,6 +12,8 @@ export const AI_ATTRIBUTION_AUDIENCE = 'ai-attribution'; export const HTML_DEPLOY_AUDIENCE = 'deploy-builder:html-deploy'; export const BITBUCKET_REPOSITORY_LIST_AUDIENCE = 'git-token-service:bitbucket-repositories'; +export const BITBUCKET_WORKSPACE_ACCESS_TOKEN_AUDIENCE = + 'git-token-service:bitbucket-workspace-access-token'; export const BITBUCKET_CODE_REVIEW_PULL_REQUEST_AUDIENCE = 'git-token-service:bitbucket-code-review:pull-request'; export const BITBUCKET_CODE_REVIEW_WEBHOOK_ENSURE_AUDIENCE = diff --git a/services/git-token-service/src/index.test.ts b/services/git-token-service/src/index.test.ts index e6eab9ca99..45a7b0a1a1 100644 --- a/services/git-token-service/src/index.test.ts +++ b/services/git-token-service/src/index.test.ts @@ -23,6 +23,7 @@ const serviceMocks = vi.hoisted(() => ({ listBitbucketRepositories: vi.fn(), resolveBitbucketToken: vi.fn(), resolveBitbucketCapabilitySubject: vi.fn(), + getBitbucketWorkspaceAuthorization: vi.fn(), })); vi.mock('cloudflare:workers', () => ({ @@ -103,6 +104,12 @@ vi.mock('./bitbucket-runtime-token-resolver.js', () => ({ resolveBitbucketCapabilitySubject: serviceMocks.resolveBitbucketCapabilitySubject, })); +vi.mock('./bitbucket-workspace-access-token-authorization-service.js', () => ({ + BitbucketWorkspaceAccessTokenAuthorizationService: class BitbucketWorkspaceAccessTokenAuthorizationService { + getAuthorization = serviceMocks.getBitbucketWorkspaceAuthorization; + }, +})); + import gitTokenServiceWorker, { GitTokenRPCEntrypoint } from './index.js'; import { GitHubTokenGenerationError } from './github-token-service.js'; @@ -246,6 +253,184 @@ describe('Bitbucket repository-list HTTP authorization', () => { }); }); +describe('Bitbucket workspace access-token release HTTP authorization', () => { + const jwtSecret = 'test-secret-that-is-at-least-32-characters'; + const organizationId = '123e4567-e89b-12d3-a456-426614174030'; + const integrationId = '123e4567-e89b-12d3-a456-426614174012'; + const workspaceUuid = '123e4567-e89b-12d3-a456-426614174044'; + const env = { NEXTAUTH_SECRET: jwtSecret } as CloudflareEnv; + const RELEASE_AUDIENCE = 'git-token-service:bitbucket-workspace-access-token'; + + type ReleaseBody = { + integrationId: string; + workspaceUuid: string; + workspaceSlug: string; + }; + + function releaseBody(overrides: Partial = {}) { + return { integrationId, workspaceUuid, workspaceSlug: 'acme', ...overrides }; + } + + async function postRelease( + body: unknown, + options: { audience?: string | null; extraClaims?: { organizationId?: string } } = {} + ): Promise { + const { token } = await signKiloToken({ + userId: 'member-1', + pepper: null, + secret: jwtSecret, + expiresInSeconds: 5 * 60, + audience: options.audience === null ? undefined : (options.audience ?? RELEASE_AUDIENCE), + extra: { + organizationId: options.extraClaims?.organizationId ?? organizationId, + }, + }); + return gitTokenServiceWorker.fetch( + new Request('https://git-token-service.test/internal/bitbucket/workspace-access-token', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }), + env + ); + } + + function availableAuthorization( + overrides: Partial<{ + integrationId: string; + workspace: { uuid: string; slug: string }; + }> = {} + ) { + return { + status: 'available', + token: 'at-released-token', + organizationId, + integrationId, + credentialId: '123e4567-e89b-12d3-a456-426614174055', + credentialVersion: 1, + providerScopes: ['repository', 'pullrequest'], + workspace: { uuid: workspaceUuid, slug: 'acme' }, + ...overrides, + }; + } + + beforeEach(() => { + serviceMocks.getBitbucketWorkspaceAuthorization.mockReset(); + }); + + it('releases the decrypted workspace token for the claimed organization', async () => { + serviceMocks.getBitbucketWorkspaceAuthorization.mockResolvedValue(availableAuthorization()); + const response = await postRelease(releaseBody()); + + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + await expect(response.json()).resolves.toEqual({ + status: 'available', + token: 'at-released-token', + workspace: { uuid: workspaceUuid, slug: 'acme' }, + }); + expect(serviceMocks.getBitbucketWorkspaceAuthorization).toHaveBeenCalledWith({ + userId: 'member-1', + orgId: organizationId, + }); + }); + + it('derives the workspace identity echo from the integration, not from the request', async () => { + serviceMocks.getBitbucketWorkspaceAuthorization.mockResolvedValue(availableAuthorization()); + const response = await postRelease(releaseBody({ workspaceSlug: 'spoofed' })); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ status: 'reconnect_required' }); + }); + + it('refuses a release when the integration identity does not match', async () => { + serviceMocks.getBitbucketWorkspaceAuthorization.mockResolvedValue( + availableAuthorization({ integrationId: '123e4567-e89b-12d3-a456-426614174099' }) + ); + const response = await postRelease(releaseBody()); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ status: 'reconnect_required' }); + }); + + it('refuses a release when the workspace uuid does not match', async () => { + serviceMocks.getBitbucketWorkspaceAuthorization.mockResolvedValue( + availableAuthorization({ + workspace: { uuid: '999e4567-e89b-12d3-a456-426614174099', slug: 'acme' }, + }) + ); + const response = await postRelease(releaseBody()); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ status: 'reconnect_required' }); + }); + + it('passes the structured authorization failures through', async () => { + for (const status of [ + 'not_connected', + 'reconnect_required', + 'invalid_request', + 'temporarily_unavailable', + ] as const) { + serviceMocks.getBitbucketWorkspaceAuthorization.mockResolvedValue({ status }); + const response = await postRelease(releaseBody()); + + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + await expect(response.json()).resolves.toEqual({ status }); + } + expect(serviceMocks.getBitbucketWorkspaceAuthorization).toHaveBeenCalledTimes(4); + }); + + it('requires an organization claim before release', async () => { + const response = await postRelease(releaseBody(), { extraClaims: { organizationId: '' } }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ error: 'organization_required' }); + expect(serviceMocks.getBitbucketWorkspaceAuthorization).not.toHaveBeenCalled(); + }); + + it('rejects a generic Kilo token without the release audience', async () => { + const response = await postRelease(releaseBody(), { audience: null }); + + expect(response.status).toBe(401); + expect(serviceMocks.getBitbucketWorkspaceAuthorization).not.toHaveBeenCalled(); + }); + + it('rejects a release body without the workspace target fields', async () => { + const response = await postRelease({ integrationId }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ status: 'invalid_request' }); + expect(serviceMocks.getBitbucketWorkspaceAuthorization).not.toHaveBeenCalled(); + }); + + it('answers 405 for non-POST requests without releasing', async () => { + const { token } = await signKiloToken({ + userId: 'member-1', + pepper: null, + secret: jwtSecret, + expiresInSeconds: 5 * 60, + audience: RELEASE_AUDIENCE, + extra: { organizationId }, + }); + const response = await gitTokenServiceWorker.fetch( + new Request('https://git-token-service.test/internal/bitbucket/workspace-access-token', { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, + }), + env + ); + + expect(response.status).toBe(405); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(serviceMocks.getBitbucketWorkspaceAuthorization).not.toHaveBeenCalled(); + }); +}); + describe('GitLab credential broker HTTP authorization', () => { const jwtSecret = 'test-secret-that-is-at-least-32-characters'; const integrationId = '123e4567-e89b-12d3-a456-426614174012'; diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index 533ab924cc..580efeab71 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -9,6 +9,7 @@ import { BITBUCKET_CODE_REVIEW_PULL_REQUEST_AUDIENCE, BITBUCKET_CODE_REVIEW_WEBHOOK_DELETE_AUDIENCE, BITBUCKET_CODE_REVIEW_WEBHOOK_ENSURE_AUDIENCE, + BITBUCKET_WORKSPACE_ACCESS_TOKEN_AUDIENCE, GITLAB_CREDENTIAL_BROKER_AUDIENCE, GITHUB_USER_AUTHORIZATION_DISCONNECT_AUDIENCE, GITHUB_USER_ACCESS_TOKEN_AUDIENCE, @@ -84,7 +85,12 @@ import { BitbucketDeleteWebhookRequestSchema, BitbucketEnsureWebhookRequestSchema, BitbucketPullRequestRequestSchema, + BitbucketWorkspaceTargetSchema, } from './bitbucket-code-review-service.js'; +import { + BitbucketWorkspaceAccessTokenAuthorizationService, + type BitbucketWorkspaceAccessTokenAuthorizationResult, +} from './bitbucket-workspace-access-token-authorization-service.js'; import { KiloSessionCapabilityCodec, KiloSessionCapabilityError, @@ -313,6 +319,7 @@ export type RedeemKiloSessionCapabilityResult = const DISCONNECT_PATH = '/internal/github-user-authorizations/disconnect'; const USER_ACCESS_TOKEN_PATH = '/internal/github-user-authorizations/token'; const BITBUCKET_REPOSITORIES_PATH = '/internal/bitbucket/repositories'; +const BITBUCKET_WORKSPACE_ACCESS_TOKEN_PATH = '/internal/bitbucket/workspace-access-token'; const BITBUCKET_CODE_REVIEW_PULL_REQUEST_PATH = '/internal/bitbucket/code-review/pull-request'; const BITBUCKET_CODE_REVIEW_WEBHOOK_ENSURE_PATH = '/internal/bitbucket/code-review/webhooks/ensure'; const BITBUCKET_CODE_REVIEW_WEBHOOK_DELETE_PATH = '/internal/bitbucket/code-review/webhooks/delete'; @@ -328,6 +335,9 @@ const BitbucketEnsureWebhookHttpRequestSchema = BitbucketEnsureWebhookRequestSch const BitbucketDeleteWebhookHttpRequestSchema = BitbucketDeleteWebhookRequestSchema.omit({ owner: true, }); +const BitbucketWorkspaceAccessTokenHttpRequestSchema = BitbucketWorkspaceTargetSchema.omit({ + owner: true, +}); const UserAccessTokenFetchRequestSchema = z.object({ op: z.literal('fetch') }); const UserAccessTokenRotateRequestSchema = z.object({ @@ -1486,9 +1496,12 @@ export default { const isGitLabCredentialBroker = url.pathname === GITLAB_CREDENTIAL_BROKER_PATH; // Credential-bearing endpoints must never be cached, including on their // shared early-return error paths (405/401/503). The GitHub user-access - // token endpoint joins the GitLab private endpoints here. + // token endpoint joins the GitLab private endpoints here, and the + // Bitbucket workspace access-token release endpoint with them. const privateNoStoreHeaders = - isGitLabCredentialBroker || url.pathname === USER_ACCESS_TOKEN_PATH + isGitLabCredentialBroker || + url.pathname === USER_ACCESS_TOKEN_PATH || + url.pathname === BITBUCKET_WORKSPACE_ACCESS_TOKEN_PATH ? { 'Cache-Control': 'no-store' } : undefined; const codeReviewAudience = bitbucketCodeReviewAudiences.get(url.pathname); @@ -1496,6 +1509,7 @@ export default { url.pathname !== DISCONNECT_PATH && url.pathname !== USER_ACCESS_TOKEN_PATH && url.pathname !== BITBUCKET_REPOSITORIES_PATH && + url.pathname !== BITBUCKET_WORKSPACE_ACCESS_TOKEN_PATH && url.pathname !== GITLAB_CREDENTIAL_BROKER_PATH && !codeReviewAudience ) { @@ -1534,11 +1548,13 @@ export default { const audience = url.pathname === BITBUCKET_REPOSITORIES_PATH ? BITBUCKET_REPOSITORY_LIST_AUDIENCE - : url.pathname === GITLAB_CREDENTIAL_BROKER_PATH - ? GITLAB_CREDENTIAL_BROKER_AUDIENCE - : url.pathname === USER_ACCESS_TOKEN_PATH - ? GITHUB_USER_ACCESS_TOKEN_AUDIENCE - : codeReviewAudience; + : url.pathname === BITBUCKET_WORKSPACE_ACCESS_TOKEN_PATH + ? BITBUCKET_WORKSPACE_ACCESS_TOKEN_AUDIENCE + : url.pathname === GITLAB_CREDENTIAL_BROKER_PATH + ? GITLAB_CREDENTIAL_BROKER_AUDIENCE + : url.pathname === USER_ACCESS_TOKEN_PATH + ? GITHUB_USER_ACCESS_TOKEN_AUDIENCE + : codeReviewAudience; authorization = url.pathname === DISCONNECT_PATH ? await verifyKiloTokenForResource(token, secret, { @@ -1568,6 +1584,77 @@ export default { } } + if (url.pathname === BITBUCKET_WORKSPACE_ACCESS_TOKEN_PATH) { + if (!authorization.organizationId) { + return Response.json( + { error: 'organization_required' }, + { status: 403, headers: privateNoStoreHeaders } + ); + } + let body: unknown; + try { + body = await readBoundedInternalJsonRequest(request); + } catch { + return Response.json( + { status: 'invalid_request' }, + { status: 400, headers: privateNoStoreHeaders } + ); + } + const parsed = BitbucketWorkspaceAccessTokenHttpRequestSchema.safeParse(body); + if (!parsed.success) { + return Response.json( + { status: 'invalid_request' }, + { status: 400, headers: privateNoStoreHeaders } + ); + } + + // The owner comes from the verified token claims, never from the body: + // the release re-resolves the org integration and decrypts the + // credential, then answers only when the requested workspace identity + // matches the integration the token belongs to. + const requested = parsed.data; + try { + const authorizationService = new BitbucketWorkspaceAccessTokenAuthorizationService(env); + const workspaceAuthorization: BitbucketWorkspaceAccessTokenAuthorizationResult = + await authorizationService.getAuthorization({ + userId: authorization.kiloUserId, + orgId: authorization.organizationId, + }); + if (workspaceAuthorization.status !== 'available') { + return Response.json( + { status: workspaceAuthorization.status }, + { headers: privateNoStoreHeaders } + ); + } + if ( + workspaceAuthorization.integrationId !== requested.integrationId || + workspaceAuthorization.workspace.uuid !== requested.workspaceUuid || + workspaceAuthorization.workspace.slug !== requested.workspaceSlug + ) { + return Response.json( + { status: 'reconnect_required' }, + { headers: privateNoStoreHeaders } + ); + } + return Response.json( + { + status: 'available', + token: workspaceAuthorization.token, + workspace: { + uuid: workspaceAuthorization.workspace.uuid, + slug: workspaceAuthorization.workspace.slug, + }, + }, + { headers: privateNoStoreHeaders } + ); + } catch { + return Response.json( + { status: 'temporarily_unavailable' }, + { headers: privateNoStoreHeaders } + ); + } + } + if (url.pathname === GITLAB_CREDENTIAL_BROKER_PATH) { let body: unknown; try {