From ab7f50f15acb6589754ae5fa7f4f7964daf7f8f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 14 Sep 2026 04:12:46 +0200 Subject: [PATCH 1/2] feat(provider-review): add server-side GitLab and Bitbucket review layer for mobile (part 1/2) https://github.com/Kilo-Org/cloud/pull/6006 --- .../provider-branch-listing.test.ts | 84 + .../cloud-agent/provider-branch-listing.ts | 383 +++++ .../bitbucket-authorization.test.ts | 376 +++++ .../bitbucket-authorization.ts | 518 ++++++ .../provider-review/bitbucket-read.test.ts | 1381 ++++++++++++++++ .../src/lib/provider-review/bitbucket-read.ts | 1358 +++++++++++++++ .../provider-review/bitbucket-write.test.ts | 992 +++++++++++ .../lib/provider-review/bitbucket-write.ts | 501 ++++++ .../gitlab-authorization.test.ts | 290 ++++ .../provider-review/gitlab-authorization.ts | 283 ++++ .../lib/provider-review/gitlab-read.test.ts | 1019 ++++++++++++ .../src/lib/provider-review/gitlab-read.ts | 855 ++++++++++ .../lib/provider-review/gitlab-write.test.ts | 928 +++++++++++ .../src/lib/provider-review/gitlab-write.ts | 567 +++++++ .../cloud-agent-next-router.branches.test.ts | 259 +++ .../src/routers/cloud-agent-next-router.ts | 31 + .../src/routers/github-pr-review-router.ts | 2 +- ...n-cloud-agent-next-router.branches.test.ts | 496 ++++++ .../organization-cloud-agent-next-router.ts | 32 + .../routers/provider-review-router.test.ts | 1016 ++++++++++++ .../web/src/routers/provider-review-router.ts | 1473 +++++++++++++++++ apps/web/src/routers/root-router.test.ts | 6 + apps/web/src/routers/root-router.ts | 2 + packages/app-shared/package.json | 1 + .../app-shared/src/analytics/event-map.ts | 7 + .../src/pr-review/intent-fingerprint.test.ts | 96 ++ .../src/pr-review/intent-fingerprint.ts | 54 +- .../src/provider-review/capabilities.test.ts | 63 + .../src/provider-review/capabilities.ts | 94 ++ .../src/provider-review/contracts.test.ts | 123 ++ .../src/provider-review/contracts.ts | 263 +++ .../app-shared/src/provider-review/index.ts | 2 + packages/trpc/src/mobile.ts | 7 +- .../internal-service-token-audiences.test.ts | 4 + .../src/internal-service-token-audiences.ts | 2 + services/git-token-service/src/index.test.ts | 185 +++ services/git-token-service/src/index.ts | 101 +- 37 files changed, 13837 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/lib/cloud-agent/provider-branch-listing.test.ts create mode 100644 apps/web/src/lib/cloud-agent/provider-branch-listing.ts create mode 100644 apps/web/src/lib/provider-review/bitbucket-authorization.test.ts create mode 100644 apps/web/src/lib/provider-review/bitbucket-authorization.ts create mode 100644 apps/web/src/lib/provider-review/bitbucket-read.test.ts create mode 100644 apps/web/src/lib/provider-review/bitbucket-read.ts create mode 100644 apps/web/src/lib/provider-review/bitbucket-write.test.ts create mode 100644 apps/web/src/lib/provider-review/bitbucket-write.ts create mode 100644 apps/web/src/lib/provider-review/gitlab-authorization.test.ts create mode 100644 apps/web/src/lib/provider-review/gitlab-authorization.ts create mode 100644 apps/web/src/lib/provider-review/gitlab-read.test.ts create mode 100644 apps/web/src/lib/provider-review/gitlab-read.ts create mode 100644 apps/web/src/lib/provider-review/gitlab-write.test.ts create mode 100644 apps/web/src/lib/provider-review/gitlab-write.ts create mode 100644 apps/web/src/routers/cloud-agent-next-router.branches.test.ts create mode 100644 apps/web/src/routers/organizations/organization-cloud-agent-next-router.branches.test.ts create mode 100644 apps/web/src/routers/provider-review-router.test.ts create mode 100644 apps/web/src/routers/provider-review-router.ts create mode 100644 packages/app-shared/src/provider-review/capabilities.test.ts create mode 100644 packages/app-shared/src/provider-review/capabilities.ts create mode 100644 packages/app-shared/src/provider-review/contracts.test.ts create mode 100644 packages/app-shared/src/provider-review/contracts.ts create mode 100644 packages/app-shared/src/provider-review/index.ts 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..5b7b87fe9f --- /dev/null +++ b/apps/web/src/lib/provider-review/bitbucket-read.test.ts @@ -0,0 +1,1381 @@ +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); + }); + + it('degrades to no task evidence when a followed task page answers 404', 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 resolves comment 101 and points at page 2, and the followed + // page is unreadable. The documented degradation (an unreadable task + // collection leaves no evidence) must apply on a cursor-followed page + // too, not only on the first. + if (parsed.searchParams.get('page') === '2') return new Response(null, { status: 404 }); + return 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).toHaveLength(2); + expect(result.threads.every(thread => thread.resolved === false)).toBe(true); + expect(result.threads.every(thread => thread.taskCount === 0)).toBe(true); + }); + + it('surfaces a reply whose root sits on an earlier comments 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')) { + // The page starts on a reply: its root (900) was served by the + // previous comments page and is not present in this page. + return jsonResponse({ + pagelen: 50, + values: [ + { + id: 901, + parent: { id: 900 }, + content: { raw: 'Reply on a root from the previous page' }, + created_on: '2026-09-02T13:00:00.000000+00:00', + user: { uuid: '{author-uuid}', nickname: 'alice', display_name: 'Alice' }, + deleted: false, + }, + { + id: 902, + content: { raw: 'Second page root' }, + created_on: '2026-09-02T14:00:00.000000+00:00', + user: { uuid: '{reviewer-uuid}', nickname: 'bob', display_name: 'Bob' }, + deleted: false, + }, + ], + next: null, + }); + } + if (parsed.pathname.endsWith('/pullrequests/12/tasks')) + return jsonResponse({ pagelen: 100, values: [], next: null }); + return jsonResponse({ pagelen: 50, values: [], next: null }); + }); + + const result = await listDiscussions(ORG_OWNER, 'acme', 'repo', 12); + + // The orphan reply is keyed by its true root id, not the reply id, so a + // resolve action still targets the root it belongs to. + const orphan = result.threads.find(thread => thread.threadId === '900'); + expect(orphan?.comments.map(comment => comment.body)).toEqual([ + 'Reply on a root from the previous page', + ]); + const secondRoot = result.threads.find(thread => thread.threadId === '902'); + expect(secondRoot?.comments.map(comment => comment.body)).toEqual(['Second page root']); + // Every comment on the page is surfaced exactly once. + const bodies = result.threads + .flatMap(thread => thread.comments.map(comment => comment.body)) + .sort(); + expect(bodies).toEqual(['Reply on a root from the previous page', 'Second page root']); + }); +}); + +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..01b21626f1 --- /dev/null +++ b/apps/web/src/lib/provider-review/bitbucket-read.ts @@ -0,0 +1,1358 @@ +/** + * 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) { + // Classify exactly like the first-page branch: the callers that degrade + // on an unreadable collection (task evidence, branch restrictions, file + // conflicts) match on a classified `BitbucketReviewError` kind, so a raw + // `BitbucketApiStatusError` from a followed page must not skip them. + 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; +}; + +type BitbucketComment = z.infer; + +type BitbucketTaskEvidence = { + commentIds: ReadonlySet; + unresolvedCommentIds: ReadonlySet; + taskCounts: ReadonlyMap; +}; + +/** + * Map one thread onto the shared DTO. `rootId` is the root comment's id — for + * a thread whose root was read on an earlier page it is an identity, not a + * comment in `threadComments`, so resolution and task counts still key off it. + */ +function mapThread( + rootId: number, + inline: BitbucketComment['inline'], + threadComments: BitbucketComment[], + taskEvidence: BitbucketTaskEvidence +): BitbucketDiscussionThread { + const anchorLine = inline?.to ?? inline?.from ?? null; + return { + threadId: String(rootId), + resolved: taskEvidence.commentIds.has(rootId) && !taskEvidence.unresolvedCommentIds.has(rootId), + path: inline?.path ?? null, + line: anchorLine, + side: inline ? (inline.to != null ? 'RIGHT' : 'LEFT') : null, + comments: threadComments.map(comment => ({ + commentId: String(comment.id), + author: mapUser(comment.user), + body: comment.content?.raw ?? '', + createdAt: comment.created_on ?? '', + })), + taskCount: taskEvidence.taskCounts.get(rootId) ?? 0, + }; +} + +/** + * 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. + * + * The collection is flat and creation-ordered, so a page can start on a reply + * whose root sits on an earlier page. Such a reply can never attach to a root + * here: it is surfaced as its own thread keyed by its true root id, using the + * reply's own inline anchor when Bitbucket sends one. Nothing is dropped, and + * thread actions still target the root the reply belongs to. + */ +function buildThreadsFromComments( + comments: BitbucketComment[], + taskEvidence: BitbucketTaskEvidence +): 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); + } + } + const threads = roots.map(root => + mapThread( + root.id, + root.inline ?? null, + [root, ...(repliesByParent.get(root.id) ?? [])], + taskEvidence + ) + ); + const rootIds = new Set(roots.map(root => root.id)); + for (const [parentId, replies] of repliesByParent) { + if (rootIds.has(parentId)) continue; + threads.push(mapThread(parentId, replies[0]?.inline ?? null, replies, taskEvidence)); + } + return threads; +} + +/** + * 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..7a997a66c1 --- /dev/null +++ b/apps/web/src/lib/provider-review/gitlab-read.test.ts @@ -0,0 +1,1019 @@ +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(); + }); + + it("reports the merge request's own changed-file total on a large MR", async () => { + mockFetchGitLabMergeRequest.mockResolvedValue({ ...mrFixture, changes_count: '1000+' }); + + const summary = await getMergeRequest(OWNER, PROJECT_PATH, 12); + + // The diff walk is capped by GitLab's own diff collection, so the total + // comes from the MR instead of the truncated walk. + expect(summary.changedFiles).toBe(1000); + }); + + it('falls back to the folded diff count while the MR total is still empty', async () => { + mockFetchGitLabMergeRequest.mockResolvedValue({ ...mrFixture, changes_count: '' }); + + const summary = await getMergeRequest(OWNER, PROJECT_PATH, 12); + + expect(summary.changedFiles).toBe(2); + }); + + it('folds diff pages past the old three-page cap so the counts are complete', async () => { + const fullPage = Array.from({ length: 50 }, (_, index) => ({ + old_path: `src/${index}.ts`, + new_path: `src/${index}.ts`, + new_file: false, + renamed_file: false, + deleted_file: false, + diff: '@@ -1 +1 @@\n-old\n+new\n', + })); + mockFetchGitLabMergeRequest.mockResolvedValue({ ...mrFixture, changes_count: '201' }); + fetchMock.mockImplementation(url => { + const parsed = new URL(String(url)); + if (parsed.pathname.endsWith('/diffs')) { + // Three full pages then a short fourth: the old fold stopped at page + // three and reported 150 files as the total. + if (parsed.searchParams.get('page') === '4') { + return Promise.resolve( + jsonResponse([{ ...fullPage[0], diff: '@@ -1 +1,2 @@\n-old\n+new\n+extra\n' }]) + ); + } + return Promise.resolve(jsonResponse(fullPage)); + } + return Promise.resolve(jsonResponse([])); + }); + + const summary = await getMergeRequest(OWNER, PROJECT_PATH, 12); + + expect(summary.changedFiles).toBe(201); + expect(summary.additions).toBe(50 * 3 + 2); + expect(summary.deletions).toBe(50 * 3 + 1); + }); + + it('folds each page into the counters as it arrives and stops at the page bound', async () => { + // Every page is full, so the walk runs to the bound instead of ending + // early. The counts must cover all of them even though no page is kept: + // the fold reads a page once and drops it before fetching the next. + const fullPage = Array.from({ length: 50 }, (_, index) => ({ + old_path: `src/${index}.ts`, + new_path: `src/${index}.ts`, + new_file: false, + renamed_file: false, + deleted_file: false, + diff: '@@ -1 +1 @@\n-old\n+new\n', + })); + const requestedPages: string[] = []; + fetchMock.mockImplementation(url => { + const parsed = new URL(String(url)); + if (parsed.pathname.endsWith('/diffs')) { + requestedPages.push(parsed.searchParams.get('page') ?? ''); + return Promise.resolve(jsonResponse(fullPage)); + } + return Promise.resolve(jsonResponse([])); + }); + + const summary = await getMergeRequest(OWNER, PROJECT_PATH, 12); + + expect(requestedPages).toHaveLength(20); + expect(requestedPages[19]).toBe('20'); + expect(summary.additions).toBe(50 * 20); + expect(summary.deletions).toBe(50 * 20); + expect(summary.changedFiles).toBe(1000); + }); +}); + +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`, + }, + ]); + }); + + it('folds every page of MR pipelines, not only the first', async () => { + const pageOne = Array.from({ length: 50 }, (_, index) => ({ + id: index + 1, + sha: 'sha-head', + ref: 'refs/merge-requests/12/merge', + status: 'success', + web_url: `${INSTANCE_URL}/-/pipelines/${index + 1}`, + name: `pipeline-${index + 1}`, + })); + const failing = { + id: 51, + sha: 'sha-head', + ref: 'feature/deploy', + status: 'failed', + web_url: `${INSTANCE_URL}/-/pipelines/51`, + name: 'failing-overtime', + }; + fetchMock.mockImplementation(url => { + const parsed = new URL(String(url)); + if (parsed.pathname.endsWith('/pipelines')) { + return Promise.resolve( + jsonResponse(parsed.searchParams.get('page') === '2' ? [failing] : pageOne) + ); + } + return Promise.resolve(jsonResponse([])); + }); + + const result = await listChecks(OWNER, PROJECT_PATH, 12); + + // The failing pipeline lives on page 2: the checks rollup exists to show + // it, so the listing must be folded past the first page. + expect(result.checks).toHaveLength(51); + expect(result.checks[50]).toEqual({ + name: 'failing-overtime', + status: 'failed', + conclusion: 'failed', + detailsUrl: `${INSTANCE_URL}/-/pipelines/51`, + }); + const pages = fetchMock.mock.calls + .map(call => new URL(String(call[0]))) + .filter(url => url.pathname.endsWith('/pipelines')) + .map(url => url.searchParams.get('page')); + expect(pages).toEqual(['1', '2']); + }); +}); + +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..c54a316baa --- /dev/null +++ b/apps/web/src/lib/provider-review/gitlab-read.ts @@ -0,0 +1,855 @@ +/** + * 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 diff-page bound for the summary's change counts: GitLab caps a merge + * request's diff collection at the project's `diff_max_files` setting (1000 + * files by default), so 20 pages of 50 fold every diff GitLab will report + * while a hostile instance still cannot stream pages forever. + */ +const MAX_SUMMARY_DIFFSTAT_PAGES = 20; +/** The MR-pipeline page bound for the checks list, same bounded-walk rule. */ +const MAX_CHECK_PAGES = 20; + +/** 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 reports the changed-file total as a string: empty while the MR is + // still computing its diff, and capped with a trailing `+` (for example + // `1000+`) once it exceeds the project's diff limit. It is never an integer. + changes_count?: string | null; + // 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 }; +} + +/** + * The MR's own changed-file total, or null when GitLab has none yet. The + * value is a string that is empty while the diff is still computing and + * carries a `+` suffix once it exceeds the project's diff limit (`1000+`); + * the digits before the cap are the largest total GitLab itself reports, so + * the summary never presents a truncated page-walk as the total. + */ +function parseChangesCount(value: string | null | undefined): number | null { + if (typeof value !== 'string') return null; + const match = value.match(/^\s*(\d+)\s*\+?\s*$/); + if (!match) return null; + const parsed = Number(match[1]); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; +} + +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 MR's own total and the 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), + ]); + const detail = mr as GitLabMergeRequestDetail; + // Counts come from the diffs. Fold every page GitLab will serve — it caps + // a merge request's diff collection at the project's `diff_max_files` + // (1000 by default), which the page bound covers — so a large merge + // request reports complete totals instead of its first 150 files. Each page + // is folded into the running counters the moment it arrives and dropped, so + // the walk never holds the parsed diff text it only reads once. + let diffFileCount = 0; + let additions = 0; + let deletions = 0; + for (let page = 1; page <= MAX_SUMMARY_DIFFSTAT_PAGES; page++) { + const diffs = await fetchDiffPage(access, mrIid, page); + for (const file of diffs) { + const counts = diffLineCounts(file.diff ?? ''); + additions += counts.additions; + deletions += counts.deletions; + } + diffFileCount += diffs.length; + if (diffs.length < GITLAB_PAGE_SIZE) break; + } + // Prefer the MR's own total: the diff walk is capped even after the page + // bound is raised, and `changes_count` covers every file GitLab counted. + const changedFiles = Math.max(parseChangesCount(detail.changes_count) ?? 0, diffFileCount); + 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, + 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. The listing paginates, and the + * checks DTO has no cursor, so every page is folded into the one rollup — a + * failing pipeline past the first page is exactly what this surface exists to + * show. + */ +export async function listChecks( + owner: GitLabReviewOwner, + projectPath: string, + mrIid: number, + instanceHint?: string +): Promise { + const access = await authorizeProject(owner, projectPath, instanceHint); + try { + const checks: ProviderPrChecksResult['checks'] = []; + for (let page = 1; page <= MAX_CHECK_PAGES; page++) { + const pipelines = await requestGitLabJson( + access, + `/api/v4/projects/${projectSegment(access)}/merge_requests/${mrIid}/pipelines`, + { query: { per_page: GITLAB_PAGE_SIZE, page } } + ); + for (const pipeline of pipelines) { + checks.push({ + name: pipeline.name || pipeline.ref, + status: pipeline.status, + conclusion: FINISHED_PIPELINE_STATUSES.has(pipeline.status) ? pipeline.status : null, + detailsUrl: pipeline.web_url, + }); + } + if (pipelines.length < GITLAB_PAGE_SIZE) break; + } + return { checks }; + } 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 bcc31460a3..59ae92e6bf 100644 --- a/apps/web/src/routers/cloud-agent-next-router.ts +++ b/apps/web/src/routers/cloud-agent-next-router.ts @@ -18,6 +18,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, @@ -696,4 +701,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 63bbcf049d..624184e9cf 100644 --- a/apps/web/src/routers/github-pr-review-router.ts +++ b/apps/web/src/routers/github-pr-review-router.ts @@ -1010,7 +1010,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 9425cd6e39..fdde84b725 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 @@ -27,6 +27,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, @@ -1016,4 +1021,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 3f003beaff..1891a2d9a2 100644 --- a/packages/app-shared/src/analytics/event-map.ts +++ b/packages/app-shared/src/analytics/event-map.ts @@ -76,12 +76,19 @@ 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', 'add_pr_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 405e40e7c7..281aa41602 100644 --- a/packages/app-shared/src/pr-review/intent-fingerprint.test.ts +++ b/packages/app-shared/src/pr-review/intent-fingerprint.test.ts @@ -110,4 +110,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 2aef194976..2a3f839a58 100644 --- a/packages/app-shared/src/pr-review/intent-fingerprint.ts +++ b/packages/app-shared/src/pr-review/intent-fingerprint.ts @@ -7,21 +7,36 @@ * 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]`. */ +import type { ProviderPrPlatform } from '../provider-review/contracts'; + export type PrLedgerIntent = | 'merge' | 'submit_review' | 'create_review_comment' | 'reply_comment' - | 'add_pr_comment'; + | 'add_pr_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'], @@ -29,19 +44,44 @@ const PR_FINGERPRINT_FIELDS: Record = { add_pr_comment: ['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 { From 62c2de28ca6cc8a90c53a6bb00790052f9364202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 14 Sep 2026 06:30:08 +0200 Subject: [PATCH 2/2] feat(mobile): bring GitLab and Bitbucket code review up to the GitHub workflow (part 2/2) https://github.com/Kilo-Org/cloud/pull/6021 --- apps/mobile/src/app/(app)/_layout.tsx | 9 + .../agent-chat/branch-picker.mounted.test.tsx | 182 ++++++ .../app/(app)/agent-chat/branch-picker.tsx | 85 +++ .../src/app/(app)/agent-chat/repo-picker.tsx | 53 +- .../[platform]/[...identity]/_layout.tsx | 130 ++++ .../[...identity]/comment-composer.tsx | 5 + .../[...identity]/file-navigator.tsx | 5 + .../[platform]/[...identity]/index.tsx | 40 ++ .../[platform]/[...identity]/merge.tsx | 5 + .../[...identity]/review-submit.tsx | 5 + .../agents/new-session-configure-form.test.ts | 34 ++ .../agents/new-session-configure-form.tsx | 11 +- .../new-session-repository-section.test.ts | 158 +++++ .../agents/new-session-repository-section.tsx | 34 +- .../new-session-repository-state.test.ts | 120 +++- .../agents/new-session-repository-state.ts | 135 +++++ .../agents/picker-search.mounted.test.tsx | 92 ++- ...epository-branch-selector.mounted.test.tsx | 362 +++++++++++ .../agents/repository-branch-selector.tsx | 200 ++++++ .../agents/session-detail-content.tsx | 3 +- .../agents/session-pr-badge.test.ts | 25 +- .../components/agents/session-pr-badge.tsx | 1 - .../agents/use-new-session-creator.test.ts | 114 +++- .../agents/use-new-session-creator.ts | 27 +- apps/mobile/src/components/picker-sheet.tsx | 9 +- .../pr-review/composer-inline-error.tsx | 2 +- .../pr-review/diff/diff-line.mounted.test.tsx | 108 ++++ .../components/pr-review/diff/diff-line.tsx | 7 +- .../diff/pr-diff-file-list-header.test.tsx | 9 +- .../diff/pr-diff-file-list-header.tsx | 15 +- .../pr-review/diff/pr-diff-file-list.test.tsx | 129 +++- .../pr-review/diff/pr-diff-file-list.tsx | 55 +- .../diff/pr-diff-file-navigator.test.tsx | 68 ++- .../pr-review/diff/pr-diff-file-navigator.tsx | 7 +- ...pr-diff-floating-actions.backdrop.test.tsx | 102 ++++ ...ff-floating-actions.badge.mounted.test.tsx | 252 ++++++++ .../diff/pr-diff-floating-actions.test.tsx | 114 ++-- .../diff/pr-diff-floating-actions.tsx | 85 +-- .../pr-review/diff/pr-diff-hunk-rows.tsx | 5 +- .../pr-diff-side-by-side-row.mounted.test.tsx | 86 +++ .../diff/pr-diff-side-by-side-row.tsx | 5 +- .../pr-review/diff/pr-diff-state-copy.ts | 41 ++ .../pr-review/discussion/comment-row.test.tsx | 40 ++ .../pr-review/discussion/comment-row.tsx | 22 +- .../discussion-thread.provider-gate.test.tsx | 243 ++++++++ .../discussion/discussion-thread.tsx | 38 +- ...pr-review-discussion-list.mounted.test.tsx | 165 +++++ .../discussion/pr-review-discussion-list.tsx | 14 +- .../pr-review/discussion/reply-input.test.ts | 154 ++++- .../pr-review/discussion/reply-input.tsx | 46 +- .../full-surface-states.mounted.test.tsx | 82 ++- .../merge/pr-merge-section-parts.tsx | 38 +- .../merge/pr-merge-section-provider.test.tsx | 166 +++++ .../merge/pr-merge-section-provider.tsx | 82 +++ .../merge/pr-merge-sheet-parts.test.tsx | 121 ++++ .../pr-review/merge/pr-merge-sheet-parts.tsx | 21 +- .../pr-review/merge/pr-merge-sheet.test.tsx | 568 +++++++++++++++++- .../pr-review/merge/pr-merge-sheet.tsx | 454 ++++++++++++-- .../pr-review/pr-form-sheet-chrome.tsx | 3 + ...-review-capability-banner.mounted.test.tsx | 82 +++ .../pr-review/pr-review-capability-banner.tsx | 37 ++ .../pr-review-checks-section.mounted.test.tsx | 221 ++++++- .../pr-review-checks-section.test.tsx | 3 + .../pr-review/pr-review-checks-section.tsx | 68 ++- .../pr-review-comment-composer-screen.tsx | 87 ++- .../pr-review-comment-composer.test.tsx | 84 +++ .../pr-review/pr-review-comment-composer.tsx | 56 +- .../pr-review-connect-gate-view.test.ts | 76 +-- .../pr-review/pr-review-connect-gate-view.ts | 29 - .../pr-review/pr-review-connect-gate.tsx | 264 +++++++- .../pr-review-discussion-tab-view.test.ts | 11 + .../pr-review-discussion-tab-view.ts | 13 +- .../pr-review-discussion-tab.test.tsx | 169 +++--- .../pr-review/pr-review-discussion-tab.tsx | 37 +- .../pr-review/pr-review-entry-recents.test.ts | 147 +++++ .../pr-review-entry-screen-test-utils.ts | 253 ++++++++ .../pr-review/pr-review-entry-screen.test.ts | 138 +++++ .../pr-review/pr-review-entry-screen.tsx | 57 +- .../pr-review-file-navigator-screen.test.tsx | 135 +++++ .../pr-review-file-navigator-screen.tsx | 77 ++- .../pr-review/pr-review-files-tab.tsx | 6 + .../pr-review-inbox-list.mounted.test.tsx | 19 +- .../pr-review/pr-review-inbox-list.tsx | 90 +-- .../pr-review-merge-screen.mounted.test.tsx | 297 +++++++++ .../pr-review/pr-review-merge-screen.tsx | 181 +++++- .../pr-review/pr-review-overview-parts.tsx | 18 +- .../pr-review/pr-review-overview.tsx | 117 +++- .../pr-review/pr-review-provider-noun.ts | 15 + .../pr-review-provider-sheet-href.ts | 40 ++ .../pr-review/pr-review-reconnect-notice.tsx | 44 +- .../pr-review-review-submit-screen.tsx | 130 +++- .../pr-review/pr-review-screen.test.tsx | 130 +++- .../components/pr-review/pr-review-screen.tsx | 165 +++-- .../pr-review/pr-review-submit.test.tsx | 190 +++++- .../components/pr-review/pr-review-submit.tsx | 192 +++++- .../pr-review/pr-review-tab-selector.tsx | 13 +- .../pr-review/recent-pr-row-state.test.ts | 45 -- .../pr-review/recent-pr-row-state.ts | 24 - apps/mobile/src/i18n/locales/af.json | 114 +++- apps/mobile/src/i18n/locales/am.json | 116 +++- apps/mobile/src/i18n/locales/ar.json | 176 ++++-- apps/mobile/src/i18n/locales/az.json | 114 +++- apps/mobile/src/i18n/locales/be.json | 116 +++- apps/mobile/src/i18n/locales/bg.json | 114 +++- apps/mobile/src/i18n/locales/bn.json | 114 +++- apps/mobile/src/i18n/locales/bs.json | 115 +++- apps/mobile/src/i18n/locales/ca.json | 117 +++- apps/mobile/src/i18n/locales/ckb.json | 114 +++- apps/mobile/src/i18n/locales/cs.json | 118 +++- apps/mobile/src/i18n/locales/cy.json | 118 +++- apps/mobile/src/i18n/locales/da.json | 116 +++- apps/mobile/src/i18n/locales/de.json | 172 ++++-- apps/mobile/src/i18n/locales/el.json | 114 +++- apps/mobile/src/i18n/locales/en.json | 88 ++- apps/mobile/src/i18n/locales/es.json | 175 ++++-- apps/mobile/src/i18n/locales/et.json | 116 +++- apps/mobile/src/i18n/locales/eu.json | 116 +++- apps/mobile/src/i18n/locales/fa.json | 116 +++- apps/mobile/src/i18n/locales/fi.json | 116 +++- apps/mobile/src/i18n/locales/fil.json | 114 +++- apps/mobile/src/i18n/locales/fr.json | 177 ++++-- apps/mobile/src/i18n/locales/ga.json | 119 +++- apps/mobile/src/i18n/locales/gl.json | 114 +++- apps/mobile/src/i18n/locales/gu.json | 114 +++- apps/mobile/src/i18n/locales/ha.json | 116 +++- apps/mobile/src/i18n/locales/he.json | 175 ++++-- apps/mobile/src/i18n/locales/hi.json | 176 ++++-- apps/mobile/src/i18n/locales/hr.json | 115 +++- apps/mobile/src/i18n/locales/ht.json | 114 +++- apps/mobile/src/i18n/locales/hu.json | 114 +++- apps/mobile/src/i18n/locales/hy.json | 114 +++- apps/mobile/src/i18n/locales/id.json | 172 ++++-- apps/mobile/src/i18n/locales/ig.json | 116 +++- apps/mobile/src/i18n/locales/is.json | 114 +++- apps/mobile/src/i18n/locales/it.json | 175 ++++-- apps/mobile/src/i18n/locales/ja.json | 174 ++++-- apps/mobile/src/i18n/locales/ka.json | 116 +++- apps/mobile/src/i18n/locales/kk.json | 114 +++- apps/mobile/src/i18n/locales/km.json | 114 +++- apps/mobile/src/i18n/locales/kn.json | 114 +++- apps/mobile/src/i18n/locales/ko.json | 172 ++++-- apps/mobile/src/i18n/locales/lo.json | 114 +++- apps/mobile/src/i18n/locales/lt.json | 116 +++- apps/mobile/src/i18n/locales/lv.json | 115 +++- apps/mobile/src/i18n/locales/mg.json | 114 +++- apps/mobile/src/i18n/locales/mi.json | 116 +++- apps/mobile/src/i18n/locales/mk.json | 114 +++- apps/mobile/src/i18n/locales/ml.json | 114 +++- apps/mobile/src/i18n/locales/mn.json | 114 +++- apps/mobile/src/i18n/locales/mr.json | 114 +++- apps/mobile/src/i18n/locales/ms.json | 116 +++- apps/mobile/src/i18n/locales/mt.json | 117 +++- apps/mobile/src/i18n/locales/my.json | 114 +++- apps/mobile/src/i18n/locales/nb.json | 116 +++- apps/mobile/src/i18n/locales/ne.json | 114 +++- apps/mobile/src/i18n/locales/nl.json | 174 ++++-- apps/mobile/src/i18n/locales/om.json | 114 +++- apps/mobile/src/i18n/locales/or.json | 116 +++- apps/mobile/src/i18n/locales/pa.json | 116 +++- apps/mobile/src/i18n/locales/pl.json | 174 ++++-- apps/mobile/src/i18n/locales/ps.json | 114 +++- apps/mobile/src/i18n/locales/pt-BR.json | 175 ++++-- apps/mobile/src/i18n/locales/pt.json | 115 +++- apps/mobile/src/i18n/locales/ro.json | 115 +++- apps/mobile/src/i18n/locales/ru.json | 176 ++++-- apps/mobile/src/i18n/locales/si.json | 116 +++- apps/mobile/src/i18n/locales/sk.json | 116 +++- apps/mobile/src/i18n/locales/sl.json | 118 +++- apps/mobile/src/i18n/locales/so.json | 114 +++- apps/mobile/src/i18n/locales/sq.json | 114 +++- apps/mobile/src/i18n/locales/sr.json | 117 +++- apps/mobile/src/i18n/locales/sv.json | 116 +++- apps/mobile/src/i18n/locales/sw.json | 114 +++- apps/mobile/src/i18n/locales/ta.json | 116 +++- apps/mobile/src/i18n/locales/te.json | 114 +++- apps/mobile/src/i18n/locales/th.json | 114 +++- apps/mobile/src/i18n/locales/tr.json | 172 ++++-- apps/mobile/src/i18n/locales/uk.json | 176 ++++-- apps/mobile/src/i18n/locales/ur.json | 114 +++- apps/mobile/src/i18n/locales/uz.json | 114 +++- apps/mobile/src/i18n/locales/vi.json | 172 ++++-- apps/mobile/src/i18n/locales/yo.json | 114 +++- apps/mobile/src/i18n/locales/zh-Hans.json | 172 ++++-- apps/mobile/src/i18n/locales/zh-Hant.json | 172 ++++-- apps/mobile/src/i18n/locales/zu.json | 114 +++- apps/mobile/src/lib/expo-router-patch.test.ts | 97 +++ .../src/lib/intl-cache-hermes-surface.test.ts | 9 +- apps/mobile/src/lib/picker-bridge.ts | 14 + .../diff/pr-diff-list-bottom-padding.test.ts | 51 -- .../diff/pr-diff-list-bottom-padding.ts | 31 +- .../diff/pr-review-file-list-state.ts | 70 ++- .../diff/use-pr-diff-context-loader.test.ts | 60 ++ .../diff/use-pr-diff-context-loader.ts | 62 +- ...se-pr-review-viewed-files.mounted.test.tsx | 92 +++ .../use-pr-review-discussion-threads.ts | 53 +- .../use-review-discussion-mutations.test.ts | 327 ++++++++-- .../use-review-discussion-mutations.ts | 353 ++++++++--- .../merge/use-pr-merge-mutations.test.ts | 284 ++++++++- .../pr-review/merge/use-pr-merge-mutations.ts | 304 ++++++++-- .../pr-review/mutation-error-display.test.ts | 54 +- .../lib/pr-review/mutation-error-display.ts | 44 +- .../pending-review-provider.mounted.test.tsx | 78 +++ .../lib/pr-review/pending-review-provider.tsx | 21 + .../src/lib/pr-review/pr-link-paste.test.ts | 76 ++- .../mobile/src/lib/pr-review/pr-link-paste.ts | 31 +- .../pr-review-connect-gate-view.test.ts | 163 +++++ .../pr-review/pr-review-connect-gate-view.ts | 54 ++ .../lib/pr-review/provider-pr-queries.test.ts | 378 ++++++++++++ .../src/lib/pr-review/provider-pr-queries.ts | 452 ++++++++++++++ .../src/lib/pr-review/provider-pr-ref.test.ts | 241 ++++++++ .../src/lib/pr-review/provider-pr-ref.ts | 308 ++++++++++ .../src/lib/pr-review/provider-pr-url.test.ts | 166 +++++ .../src/lib/pr-review/provider-pr-url.ts | 174 ++++++ .../lib/pr-review/recent-pr-row-state.test.ts | 91 +++ .../src/lib/pr-review/recent-pr-row-state.ts | 47 ++ .../src/lib/pr-review/recent-prs.test.ts | 169 ++++++ apps/mobile/src/lib/pr-review/recent-prs.ts | 71 ++- .../use-check-provider-connection.ts | 66 ++ .../pr-review/use-pr-review-mutations.test.ts | 434 ++++++++++++- .../lib/pr-review/use-pr-review-mutations.ts | 299 ++++++++- .../use-provider-inbox.mounted.test.tsx | 287 +++++++++ .../use-provider-inbox.test-helpers.ts | 48 ++ .../lib/pr-review/use-provider-inbox.test.ts | 336 +++++++++++ .../src/lib/pr-review/use-provider-inbox.ts | 370 ++++++++++++ .../src/lib/pr-review/viewed-files.test.ts | 65 ++ apps/mobile/src/lib/pr-review/viewed-files.ts | 27 +- apps/mobile/src/lib/route-registry.ts | 5 + .../src/lib/session-pr-navigation.test.ts | 37 +- apps/mobile/src/lib/session-pr-navigation.ts | 23 +- apps/mobile/src/lib/universal-link-paths.js | 2 + .../src/lib/use-new-session-repos.test.ts | 257 +++++++- apps/mobile/src/lib/use-new-session-repos.ts | 117 +++- .../.well-known/apple-app-site-association | 2 + .../provider-review/bitbucket-read.test.ts | 31 + .../src/lib/provider-review/bitbucket-read.ts | 22 +- .../src/lib/provider-review/gitlab-read.ts | 4 +- .../src/universal-links/routes.test.ts | 63 +- .../app-shared/src/universal-links/routes.ts | 44 +- patches/expo-router@57.0.20.patch | 30 +- pnpm-lock.yaml | 16 +- tools/i18n/check-catalogs.mjs | 6 +- 241 files changed, 22940 insertions(+), 4131 deletions(-) create mode 100644 apps/mobile/src/app/(app)/agent-chat/branch-picker.mounted.test.tsx create mode 100644 apps/mobile/src/app/(app)/agent-chat/branch-picker.tsx create mode 100644 apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/_layout.tsx create mode 100644 apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/comment-composer.tsx create mode 100644 apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/file-navigator.tsx create mode 100644 apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/index.tsx create mode 100644 apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/merge.tsx create mode 100644 apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/review-submit.tsx create mode 100644 apps/mobile/src/components/agents/new-session-repository-section.test.ts create mode 100644 apps/mobile/src/components/agents/repository-branch-selector.mounted.test.tsx create mode 100644 apps/mobile/src/components/agents/repository-branch-selector.tsx create mode 100644 apps/mobile/src/components/pr-review/diff/diff-line.mounted.test.tsx create mode 100644 apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.backdrop.test.tsx create mode 100644 apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.badge.mounted.test.tsx create mode 100644 apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.mounted.test.tsx create mode 100644 apps/mobile/src/components/pr-review/diff/pr-diff-state-copy.ts create mode 100644 apps/mobile/src/components/pr-review/discussion/discussion-thread.provider-gate.test.tsx create mode 100644 apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.mounted.test.tsx create mode 100644 apps/mobile/src/components/pr-review/merge/pr-merge-section-provider.test.tsx create mode 100644 apps/mobile/src/components/pr-review/merge/pr-merge-section-provider.tsx create mode 100644 apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.test.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-capability-banner.mounted.test.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-capability-banner.tsx delete mode 100644 apps/mobile/src/components/pr-review/pr-review-connect-gate-view.ts create mode 100644 apps/mobile/src/components/pr-review/pr-review-entry-recents.test.ts create mode 100644 apps/mobile/src/components/pr-review/pr-review-entry-screen-test-utils.ts create mode 100644 apps/mobile/src/components/pr-review/pr-review-entry-screen.test.ts create mode 100644 apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.test.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-merge-screen.mounted.test.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-provider-noun.ts create mode 100644 apps/mobile/src/components/pr-review/pr-review-provider-sheet-href.ts delete mode 100644 apps/mobile/src/components/pr-review/recent-pr-row-state.test.ts delete mode 100644 apps/mobile/src/components/pr-review/recent-pr-row-state.ts create mode 100644 apps/mobile/src/lib/expo-router-patch.test.ts delete mode 100644 apps/mobile/src/lib/pr-review/diff/pr-diff-list-bottom-padding.test.ts create mode 100644 apps/mobile/src/lib/pr-review/diff/use-pr-diff-context-loader.test.ts create mode 100644 apps/mobile/src/lib/pr-review/diff/use-pr-review-viewed-files.mounted.test.tsx create mode 100644 apps/mobile/src/lib/pr-review/pr-review-connect-gate-view.test.ts create mode 100644 apps/mobile/src/lib/pr-review/pr-review-connect-gate-view.ts create mode 100644 apps/mobile/src/lib/pr-review/provider-pr-queries.test.ts create mode 100644 apps/mobile/src/lib/pr-review/provider-pr-queries.ts create mode 100644 apps/mobile/src/lib/pr-review/provider-pr-ref.test.ts create mode 100644 apps/mobile/src/lib/pr-review/provider-pr-ref.ts create mode 100644 apps/mobile/src/lib/pr-review/provider-pr-url.test.ts create mode 100644 apps/mobile/src/lib/pr-review/provider-pr-url.ts create mode 100644 apps/mobile/src/lib/pr-review/recent-pr-row-state.test.ts create mode 100644 apps/mobile/src/lib/pr-review/recent-pr-row-state.ts create mode 100644 apps/mobile/src/lib/pr-review/use-check-provider-connection.ts create mode 100644 apps/mobile/src/lib/pr-review/use-provider-inbox.mounted.test.tsx create mode 100644 apps/mobile/src/lib/pr-review/use-provider-inbox.test-helpers.ts create mode 100644 apps/mobile/src/lib/pr-review/use-provider-inbox.test.ts create mode 100644 apps/mobile/src/lib/pr-review/use-provider-inbox.ts diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx index 41481af941..5cc0ed95c1 100644 --- a/apps/mobile/src/app/(app)/_layout.tsx +++ b/apps/mobile/src/app/(app)/_layout.tsx @@ -158,6 +158,15 @@ export default function AppLayout() { headerShown: false, }} /> + ({ back: vi.fn() })); +const slot = vi.hoisted(() => ({ bridge: undefined as BranchPickerBridge | undefined })); + +vi.mock('expo-router', () => ({ + useRouter: () => router, +})); +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + ScrollView: 'ScrollView', + View: 'View', +})); +vi.mock('@/components/picker-sheet', () => ({ + // The fake shell renders the header contract (title + both dismiss + // controls) and the rows below it, so a test can assert the header + // controls and the rows in one tree. + PickerSheet: (props: { + title: string; + onDone: () => void; + onCancel?: () => void; + expired?: boolean; + children?: React.ReactNode; + }) => + createElement( + 'PickerSheet', + { + title: props.title, + expired: props.expired === true, + onCancel: props.onCancel, + onDone: props.onDone, + }, + props.children + ), +})); +vi.mock('@/components/ui/text', async () => { + const React = await import('react'); + return { Text: 'Text', TextClassContext: React.createContext(undefined) }; +}); +vi.mock('@/components/ui/icons', () => ({ Check: 'Check' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ primary: '#0a84ff' }), +})); +vi.mock('@/lib/route-registry', () => ({ + UNFENCED_ROUTE_KEY: 'unscoped', + useRouteRegistry: vi.fn(), + branchPickerSlot: { + get: () => slot.bridge, + clear: vi.fn(), + }, +})); + +function texts(renderer: TestRenderer.ReactTestRenderer): string[] { + return renderer.root + .findAllByType('Text' as never) + .flatMap(node => node.children) + .filter((child): child is string => typeof child === 'string'); +} + +function branchLabel(branch: string): string { + return i18n.t('agentChat.newSession.branchAccessibility', { label: branch }); +} + +function branchRow(renderer: TestRenderer.ReactTestRenderer, branch: string) { + return renderer.root.findAll( + node => + node.props.accessibilityLabel === branchLabel(branch) && + typeof node.props.onPress === 'function' + )[0]; +} + +/** Fire a node's `onPress`, the way a tap would. */ +function press(node: TestRenderer.ReactTestInstance | undefined) { + act(() => { + (node?.props.onPress as (() => void) | undefined)?.(); + }); +} + +/** Mount the screen inside act, so i18n's subscription settles inside it. */ +function mount(): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | null } = { current: null }; + act(() => { + ref.current = TestRenderer.create(createElement(BranchPickerScreen)); + }); + const created = ref.current; + if (created === null) { + throw new Error('the branch picker route did not render'); + } + return created; +} + +function setBridge(overrides: Partial = {}) { + slot.bridge = { + branches: ['main', 'release/2.0'], + defaultBranch: 'main', + selectedBranch: 'main', + onSelect: vi.fn(() => undefined), + ...overrides, + }; +} + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + slot.bridge = undefined; + router.back.mockClear(); +}); + +describe('BranchPickerScreen', () => { + it('renders the header shell with both dismiss controls and one row per branch', () => { + setBridge(); + const renderer = mount(); + + const shell = renderer.root.findByType('PickerSheet' as never); + expect(shell.props.title).toBe(i18n.t('agentChat.newSession.branchPickerTitle')); + expect(typeof shell.props.onCancel).toBe('function'); + expect(typeof shell.props.onDone).toBe('function'); + + expect(branchRow(renderer, 'main')).toBeDefined(); + expect(branchRow(renderer, 'release/2.0')).toBeDefined(); + }); + + it('marks the provider default row and the selected row', () => { + setBridge({ selectedBranch: 'release/2.0' }); + const renderer = mount(); + + expect(texts(renderer)).toContain(i18n.t('agentChat.newSession.branchDefault')); + expect(branchRow(renderer, 'release/2.0')?.props.accessibilityState).toEqual({ + selected: true, + }); + expect(branchRow(renderer, 'main')?.props.accessibilityState).toEqual({ selected: false }); + }); + + it('hands the picked branch name back and dismisses', () => { + const onSelect = vi.fn(() => undefined); + setBridge({ onSelect }); + const renderer = mount(); + + press(branchRow(renderer, 'release/2.0')); + + expect(onSelect).toHaveBeenCalledWith('release/2.0'); + expect(router.back).toHaveBeenCalledTimes(1); + }); + + it('hands the default branch name back too — the trigger owns the override decision', () => { + const onSelect = vi.fn(() => undefined); + setBridge({ onSelect }); + const renderer = mount(); + + press(branchRow(renderer, 'main')); + + expect(onSelect).toHaveBeenCalledWith('main'); + }); + + it('dismisses from the header Cancel without reporting a pick', () => { + const onSelect = vi.fn(() => undefined); + setBridge({ onSelect }); + const renderer = mount(); + + const shell = renderer.root.findByType('PickerSheet' as never); + act(() => { + (shell.props.onCancel as () => void)(); + }); + + expect(router.back).toHaveBeenCalledTimes(1); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('renders the standard expired shell when the slot is gone', () => { + const renderer = mount(); + + const shell = renderer.root.findByType('PickerSheet' as never); + expect(shell.props.expired).toBe(true); + expect(texts(renderer)).not.toContain('main'); + }); +}); diff --git a/apps/mobile/src/app/(app)/agent-chat/branch-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/branch-picker.tsx new file mode 100644 index 0000000000..2585e7a9be --- /dev/null +++ b/apps/mobile/src/app/(app)/agent-chat/branch-picker.tsx @@ -0,0 +1,85 @@ +import { useRouter } from 'expo-router'; +import { Check } from '@/components/ui/icons'; +import { useState } from 'react'; +import { Pressable, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; + +import { PickerSheet } from '@/components/picker-sheet'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { type BranchPickerBridge } from '@/lib/picker-bridge'; +import { branchPickerSlot, UNFENCED_ROUTE_KEY, useRouteRegistry } from '@/lib/route-registry'; + +/** + * The new-session branch picker, presented as the standard formSheet (same + * shell as the repo/mode/model pickers). The shell's header carries the + * dismiss controls and the rows render below it, so a Cancel control can + * never float over — or drift away from — the branch rows. + */ +export default function BranchPickerScreen() { + const router = useRouter(); + const colors = useThemeColors(); + const { t } = useTranslation(); + useRouteRegistry(UNFENCED_ROUTE_KEY); + // Lazy init reads the slot synchronously on first render — no effect, no + // "Options expired" flash before a later effect populates state. + const [bridge] = useState(() => branchPickerSlot.get(UNFENCED_ROUTE_KEY)); + + function close() { + router.back(); + } + + function handleSelect(picker: BranchPickerBridge, branch: string) { + picker.onSelect(branch); + branchPickerSlot.clear(UNFENCED_ROUTE_KEY); + router.back(); + } + + if (!bridge) { + return ( + + ); + } + + return ( + + + {bridge.branches.map(branch => { + const isSelected = branch === bridge.selectedBranch; + const isDefault = branch === bridge.defaultBranch; + return ( + { + handleSelect(bridge, branch); + }} + > + + {branch} + + {isDefault ? ( + + {t('agentChat.newSession.branchDefault')} + + ) : null} + {isSelected ? : null} + + ); + })} + + + ); +} diff --git a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx index 3caa8c6961..0085a132fb 100644 --- a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx @@ -2,14 +2,14 @@ import { useFocusEffect, useRouter } from 'expo-router'; import * as Haptics from 'expo-haptics'; import { Check, Info, Lock, Search, SearchX, Unlock } from '@/components/ui/icons'; import { useCallback, useMemo, useRef, useState } from 'react'; -import { FlatList, Pressable, TextInput, View } from 'react-native'; +import { Pressable, TextInput, View } from 'react-native'; import { useTranslation } from 'react-i18next'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { EmptyState } from '@/components/empty-state'; import { PickerSheet } from '@/components/picker-sheet'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { useOrganization } from '@/lib/organization-context'; import { REPO_PLATFORM_LABEL_KEYS, type RepoOption } from '@/lib/picker-bridge'; import { repoPickerSlot, UNFENCED_ROUTE_KEY, useRouteRegistry } from '@/lib/route-registry'; import { filterRepoPickerOptions } from '@/lib/repo-picker-filter'; @@ -21,8 +21,8 @@ type PickerListItem = export default function RepoPickerScreen() { const router = useRouter(); const colors = useThemeColors(); - const { bottom } = useSafeAreaInsets(); const { t } = useTranslation(); + const { organizationId } = useOrganization(); const [search, setSearch] = useState(''); const [bridge, setBridge] = useState(() => repoPickerSlot.get(UNFENCED_ROUTE_KEY)); @@ -102,7 +102,6 @@ export default function RepoPickerScreen() { @@ -136,17 +135,18 @@ export default function RepoPickerScreen() { } /> ) : ( - item.key} - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" - contentContainerStyle={{ paddingBottom: bottom }} - renderItem={({ item }) => { + // Mapped rows inside the shell ScrollView instead of a FlatList: the + // FlatList stretches into the space the formSheet offers and its rows + // painted over the pinned search header while scrolling. The shell + // scroll view starts below the header, so a row can never overlap it. + + {listItems.map(item => { if (item.kind === 'header') { return ( - + {t(item.titleKey)} ); @@ -156,6 +156,7 @@ export default function RepoPickerScreen() { const rowLabel = `${platformName} ${repo.fullName}`; return ( { handleSelect(`${repo.platform}:${repo.fullName}`); @@ -182,9 +183,31 @@ export default function RepoPickerScreen() { ) : null} ); - }} - /> + })} + {renderBitbucketNote()} + )} ); + + /** + * Personal Bitbucket never lists repositories (organization-only), so the + * grouped list would end at GitLab with nothing explaining the gap. The + * note renders once, after the provider sections, in Personal context only. + * An absent Bitbucket section says nothing about scope: all its rows may + * be in Recents, or an organization may have no Bitbucket repositories. + */ + function renderBitbucketNote() { + if (search.trim() || !bridge || organizationId !== null) { + return null; + } + return ( + + + {t('agentChat.repoPicker.platformBitbucket')} + + {t('agentChat.newSession.bitbucketOrganizationsOnly')} + + ); + } } diff --git a/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/_layout.tsx b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/_layout.tsx new file mode 100644 index 0000000000..7bd9c53700 --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/_layout.tsx @@ -0,0 +1,130 @@ +import { type Href, Redirect, Stack, useLocalSearchParams } from 'expo-router'; +import { useMemo } from 'react'; + +import { appUnlockScreenLayout } from '@/components/app-unlock-screen'; +import { InvalidRouteState } from '@/components/invalid-route-state'; +import { PrReviewConnectGate } from '@/components/pr-review/pr-review-connect-gate'; +import { useFormSheetDetents } from '@/lib/form-sheet'; +import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; +import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh'; +import { useOrganization } from '@/lib/organization-context'; +import { + pendingReviewDraftKey, + PendingReviewProvider, +} from '@/lib/pr-review/pending-review-provider'; +import { + parseProviderPrRoute, + providerPrRefKey, + providerPrRoutePath, + ProviderPrScopeProvider, + providerPrTriple, +} from '@/lib/pr-review/provider-pr-ref'; +import { parseParam } from '@/lib/route-params'; + +type Params = { + platform: string; + identity: string[]; + instance?: string; +}; + +/** + * Param guard + scope hoist for the provider PR-review surface. + * + * The route is `[platform]/[...identity]`, where the LAST identity segment is + * the number (a GitLab MR iid, a Bitbucket PR id) and everything before it is + * the project path — a GitLab FULL nested path (`group/sub/repo`) or a + * Bitbucket `workspace/repo`. `parseProviderPrRoute` validates every segment, + * so a hand-built deep link with a missing, repeated or non-numeric segment + * never reaches a query. + * + * GitHub keeps its original `[owner]/[repo]/[number]` route untouched — this + * layout only redirects a hand-built `/pr-review/github/...` link there so + * that surface, its connect gate and its write sheets stay exactly as they + * were. + * + * The scope (ref + organization) is published in context rather than threaded + * through props: the diff list, the file navigator and the discussion tree + * take the GitHub-shaped `owner`/`repo`/`number` triple, and reading the real + * ref from context moves their queries to the right provider without a + * per-provider copy of that tree. + */ +export default function ProviderPrReviewLayout() { + const params = useLocalSearchParams(); + const platform = parseParam(params.platform) ?? ''; + // A catch-all param is a fresh array on every render; the joined form is a + // stable dependency, and a `/` inside a segment is percent-encoded by + // `providerPrRoutePath`, so splitting it back is lossless. + const identity = Array.isArray(params.identity) + ? params.identity.join('/') + : (parseParam(params.identity) ?? ''); + const instance = parseParam(params.instance) ?? ''; + const { organizationId } = useOrganization(); + const { fullSheetDetent } = useFormSheetDetents(); + const { userId } = useCurrentUserId(); + useRouteForegroundRefresh([[['providerReview']]]); + + const ref = useMemo( + () => + parseProviderPrRoute({ + platform, + identity: identity.split('/'), + instance: instance || undefined, + }), + [platform, identity, instance] + ); + const scope = useMemo(() => (ref ? { ref, organizationId } : null), [ref, organizationId]); + + if (!ref || !scope) { + return ; + } + + if (ref.platform === 'github') { + return ; + } + + // One draft queue per PR/MR: the GitHub-shaped key the store already uses, + // suffixed with the s1 collision-free ref identity so a GitLab MR and a + // GitHub PR that share `owner/repo#number` — and one project reached on two + // GitLab instances — never share a queue. + const triple = providerPrTriple(ref); + const draftEntityKey = `${pendingReviewDraftKey(triple.owner, triple.repo, triple.number)}@${providerPrRefKey(ref)}`; + + const sheetOptions = { + presentation: 'formSheet' as const, + sheetAllowedDetents: [0.5, fullSheetDetent] as [number, number], + sheetInitialDetentIndex: 'last' as const, + sheetGrabberVisible: true, + headerShown: false, + }; + + return ( + + + {/* The provider-aware connect gate (s7): a disconnected reader can + never reach the authenticated queries and mutations below, and a + Bitbucket personal scope gets the terminal org-only explanation + instead of a retry that could not succeed. */} + + + {/* Register the overview first, exactly like the GitHub layout: + unregistered routes sort after registered siblings, so without + this the initial screen is the comment-composer formSheet + instead of the PR/MR overview. */} + + {/* The three write sheets (s6) are siblings of the GitHub route's + sheets: they mount inside this layout, so they see the provider + scope and this PR's single `PendingReviewProvider` queue. */} + + + + + + + + + ); +} diff --git a/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/comment-composer.tsx b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/comment-composer.tsx new file mode 100644 index 0000000000..4a141c120b --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/comment-composer.tsx @@ -0,0 +1,5 @@ +import { PrReviewCommentComposerScreen } from '@/components/pr-review/pr-review-comment-composer-screen'; + +export default function ProviderPrReviewCommentComposerRoute() { + return ; +} diff --git a/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/file-navigator.tsx b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/file-navigator.tsx new file mode 100644 index 0000000000..f5e1922b64 --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/file-navigator.tsx @@ -0,0 +1,5 @@ +import { PrReviewFileNavigatorScreen } from '@/components/pr-review/pr-review-file-navigator-screen'; + +export default function ProviderPrReviewFileNavigatorRoute() { + return ; +} diff --git a/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/index.tsx b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/index.tsx new file mode 100644 index 0000000000..33d1ce0458 --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/index.tsx @@ -0,0 +1,40 @@ +import { type Href, Stack, useLocalSearchParams } from 'expo-router'; + +import { InvalidRouteState } from '@/components/invalid-route-state'; +import { PrReviewScreen } from '@/components/pr-review/pr-review-screen'; +import { parseProviderPrRoute, providerPrTriple } from '@/lib/pr-review/provider-pr-ref'; +import { parseParam } from '@/lib/route-params'; + +type Params = { + platform: string; + identity: string[]; + instance?: string; +}; + +/** + * The provider PR/MR detail screen. The layout above already validated the + * route and published the scope, so the screen renders through the same tree + * GitHub uses; the triple it takes is the GitHub-shaped identity its stores + * are keyed on, while its queries follow the ref from the scope. + */ +export default function ProviderPrReviewIndexRoute() { + const params = useLocalSearchParams(); + const ref = parseProviderPrRoute({ + platform: parseParam(params.platform) ?? '', + identity: params.identity, + instance: params.instance, + }); + + if (!ref) { + return ; + } + + const { owner, repo, number } = providerPrTriple(ref); + + return ( + <> + + + + ); +} diff --git a/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/merge.tsx b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/merge.tsx new file mode 100644 index 0000000000..e337d7891b --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/merge.tsx @@ -0,0 +1,5 @@ +import { PrReviewMergeScreen } from '@/components/pr-review/pr-review-merge-screen'; + +export default function ProviderPrReviewMergeRoute() { + return ; +} diff --git a/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/review-submit.tsx b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/review-submit.tsx new file mode 100644 index 0000000000..a6f5f35433 --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/review-submit.tsx @@ -0,0 +1,5 @@ +import { PrReviewReviewSubmitScreen } from '@/components/pr-review/pr-review-review-submit-screen'; + +export default function ProviderPrReviewSubmitRoute() { + return ; +} diff --git a/apps/mobile/src/components/agents/new-session-configure-form.test.ts b/apps/mobile/src/components/agents/new-session-configure-form.test.ts index b22f402e03..a9d8b94e9c 100644 --- a/apps/mobile/src/components/agents/new-session-configure-form.test.ts +++ b/apps/mobile/src/components/agents/new-session-configure-form.test.ts @@ -48,6 +48,9 @@ vi.mock('react-native', () => ({ ScrollView: 'ScrollView', View: 'View', })); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 44, left: 0, right: 0 }), +})); // ── sub-components ───────────────────────────────────────────────── vi.mock('@/components/agents/new-session-prompt', () => ({ @@ -134,6 +137,26 @@ function findElementByType(node: Node, typeName: string): Record { ); }); + // ── Case 14: bottom navigation-bar clearance ── + it('reserves the bottom safe-area inset so the Start action clears the navigation bar', async () => { + const { NewSessionConfigureForm } = await import('./new-session-configure-form'); + + // The mocked inset is 44; the helper floors at 16 and adds 16. + // eslint-disable-next-line new-cap -- plain function call, matching repo test convention + const element = NewSessionConfigureForm(defaultProps()) as Node; + + expect(findElementHeight(element)).toBe(60); + }); + // ── Case 13: reorder wiring lock ── it('wires onMoveAttachment and onReorderAttachments through to NewSessionPrompt', async () => { const { NewSessionConfigureForm } = await import('./new-session-configure-form'); diff --git a/apps/mobile/src/components/agents/new-session-configure-form.tsx b/apps/mobile/src/components/agents/new-session-configure-form.tsx index 6ebae1b378..424c583f85 100644 --- a/apps/mobile/src/components/agents/new-session-configure-form.tsx +++ b/apps/mobile/src/components/agents/new-session-configure-form.tsx @@ -1,7 +1,6 @@ import { type ReactNode, type RefObject } from 'react'; import { ScrollView, View } from 'react-native'; import { useTranslation } from 'react-i18next'; - import { InstanceSelector } from '@/components/agents/instance-selector'; import { LaunchFolderField } from '@/components/agents/folder-selector'; import { renderProfileRow } from '@/components/agents/new-session-profile-row'; @@ -30,6 +29,7 @@ import { type ModelOption } from '@/lib/hooks/use-available-models'; import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; import { type InstancePickerInstance, type ModelPickerSelection } from '@/lib/picker-bridge'; import { remoteSpawnInstanceDisconnectedNote } from '@/lib/remote-submit-outcome'; +import { useDetailScreenBottomPadding } from '@/lib/screen-insets'; type NewSessionConfigureFormProps = { // Prompt / model / attachments (Cloud Agent only). @@ -165,6 +165,10 @@ export function NewSessionConfigureForm({ }: Readonly) { const { t } = useTranslation(); const colors = useThemeColors(); + // Clears the system navigation bar under the scroll content. Without it the + // primary Start action can sit in the bar's translucent region a formSheet + // leaves exposed below itself (the picker's bottom strip showed its sliver). + const bottomClearance = useDetailScreenBottomPadding(); const isRemote = runOnInstance !== null; const isStarting = isRemote ? isSpawningRemote : isCreating; const runOnNote = @@ -214,9 +218,10 @@ export function NewSessionConfigureForm({ return ( + + ); } diff --git a/apps/mobile/src/components/agents/new-session-repository-section.test.ts b/apps/mobile/src/components/agents/new-session-repository-section.test.ts new file mode 100644 index 0000000000..27a92212a7 --- /dev/null +++ b/apps/mobile/src/components/agents/new-session-repository-section.test.ts @@ -0,0 +1,158 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/components/home/agent-sessions-section.test.ts) */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { i18n } from '@/i18n'; +import { NewSessionRepositorySection } from './new-session-repository-section'; +import { + getSelectedBranchOverride, + type NewSessionRepository, + type RepositoryGroup, + resetSelectedBranchOverrides, + setSelectedBranchOverride, +} from './new-session-repository-state'; + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + View: 'View', +})); +vi.mock('@/components/ui/text', async () => { + const React = await import('react'); + return { Text: 'Text', TextClassContext: React.createContext(undefined) }; +}); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/icons', () => ({ ExternalLink: 'ExternalLink', RefreshCw: 'RefreshCw' })); +vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' })); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); +vi.mock('@/components/agents/repo-selector', () => ({ RepoSelector: 'RepoSelector' })); +vi.mock('@/components/agents/repository-branch-selector', () => ({ + RepositoryBranchSelector: 'RepositoryBranchSelector', +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ foreground: '#000000', mutedForeground: '#777777' }), +})); + +const githubRow: NewSessionRepository = { + platform: 'github', + fullName: 'owner/repo', + isPrivate: false, +}; +const gitlabRow: NewSessionRepository = { + platform: 'gitlab', + fullName: 'owner/repo', + isPrivate: false, +}; + +const group = ( + key: RepositoryGroup['key'], + status: RepositoryGroup['status'], + repositories: NewSessionRepository[] = [] +): RepositoryGroup => ({ key, status, repositories }); + +function mountSection(overrides: { + value?: string; + repositories?: NewSessionRepository[]; + groups?: RepositoryGroup[]; +}) { + const renderer: { current: TestRenderer.ReactTestRenderer | null } = { current: null }; + act(() => { + renderer.current = TestRenderer.create( + createElement(NewSessionRepositorySection, { + disabled: false, + isRetrying: false, + onChange: vi.fn(() => undefined), + onConnect: vi.fn(() => undefined), + onRefreshRepos: vi.fn(() => undefined), + repositories: overrides.repositories ?? [githubRow, gitlabRow], + recents: [], + groups: overrides.groups ?? [group('github', 'repos'), group('gitlab', 'repos')], + value: overrides.value ?? '', + }) + ); + }); + const created = renderer.current; + if (created === null) { + throw new Error('the section did not render'); + } + return created; +} + +function branchSelectorProps(renderer: TestRenderer.ReactTestRenderer) { + return renderer.root.findAllByType('RepositoryBranchSelector' as never)[0]?.props as { + repository: NewSessionRepository | null; + disabled: boolean; + }; +} + +function renderedText(renderer: TestRenderer.ReactTestRenderer): string[] { + return renderer.root + .findAllByType('Text' as never) + .flatMap(node => node.children) + .filter((child): child is string => typeof child === 'string'); +} + +beforeEach(() => { + resetSelectedBranchOverrides(); +}); + +describe('NewSessionRepositorySection branch row', () => { + it('hands the branch selector the resolved repository row', () => { + const renderer = mountSection({ value: 'github:owner/repo' }); + + expect(branchSelectorProps(renderer).repository).toEqual(githubRow); + }); + + it('keeps same-named rows on two providers distinct', () => { + const renderer = mountSection({ value: 'gitlab:owner/repo' }); + + expect(branchSelectorProps(renderer).repository).toEqual(gitlabRow); + }); + + it('offers no branch row until a repository is selected', () => { + const renderer = mountSection({ value: '' }); + + expect(branchSelectorProps(renderer).repository).toBeNull(); + }); + + it('clears a stale branch override when the section mounts', () => { + setSelectedBranchOverride(githubRow, 'release/2.0'); + + mountSection({ value: 'github:owner/repo' }); + + expect(getSelectedBranchOverride(githubRow)).toBeNull(); + }); + + it('clears the branch override when the section unmounts', () => { + const renderer = mountSection({ value: 'github:owner/repo' }); + setSelectedBranchOverride(githubRow, 'release/2.0'); + + act(() => { + renderer.unmount(); + }); + + expect(getSelectedBranchOverride(githubRow)).toBeNull(); + }); +}); + +describe('NewSessionRepositorySection Bitbucket connect card', () => { + it('states outright that Bitbucket is organizations-only', () => { + const renderer = mountSection({ + groups: [group('github', 'repos'), group('gitlab', 'repos'), group('bitbucket', 'connect')], + }); + + expect(renderedText(renderer)).toContain( + i18n.t('agentChat.newSession.bitbucketOrganizationsOnly') + ); + }); + + it('leaves the GitHub connect card free of the Bitbucket restriction', () => { + const renderer = mountSection({ + groups: [group('github', 'connect'), group('gitlab', 'repos')], + }); + + expect(renderedText(renderer)).not.toContain( + i18n.t('agentChat.newSession.bitbucketOrganizationsOnly') + ); + }); +}); diff --git a/apps/mobile/src/components/agents/new-session-repository-section.tsx b/apps/mobile/src/components/agents/new-session-repository-section.tsx index 0022bb02d6..415bff7def 100644 --- a/apps/mobile/src/components/agents/new-session-repository-section.tsx +++ b/apps/mobile/src/components/agents/new-session-repository-section.tsx @@ -1,4 +1,4 @@ -import { Fragment, type ReactElement } from 'react'; +import { Fragment, type ReactElement, useEffect } from 'react'; import { View } from 'react-native'; import { ActivityIndicator } from '@/components/ui/activity-indicator'; import { useTranslation } from 'react-i18next'; @@ -8,11 +8,13 @@ import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { QueryError } from '@/components/query-error'; import { RepoSelector } from '@/components/agents/repo-selector'; +import { RepositoryBranchSelector } from '@/components/agents/repository-branch-selector'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type NewSessionRepository, type RepositoryGroup, type RepositoryPlatform, + resetSelectedBranchOverrides, } from './new-session-repository-state'; type NewSessionRepositorySectionProps = { @@ -65,6 +67,16 @@ const PROVIDER_COPY = { } >; +/** + * The restriction a provider's connect card must state outright. Bitbucket + * connects for an organization, never for a personal account, so "connect it" + * can never read as a promise that a personal session will get Bitbucket + * repositories — or their branches. + */ +function connectNoteKey(platform: RepositoryPlatform): string | undefined { + return platform === 'bitbucket' ? 'agentChat.newSession.bitbucketOrganizationsOnly' : undefined; +} + /** * Provider-aware repository section. One group per provider renders its own * connect/empty/error state independently, and the picker trigger lists every @@ -87,6 +99,22 @@ export function NewSessionRepositorySection({ const hasRepos = repositories.length > 0; const anyLoading = groups.some(group => group.status === 'loading'); + // The picker reports `platform:fullName`; resolve it to the row so the branch + // selector queries (and keys) the full repository identity. The prefill seeds + // the same platform-qualified key, so no bare-fullName fallback is needed — + // one would bind a same-named row on another provider. + const selectedRepository = + repositories.find(repository => `${repository.platform}:${repository.fullName}` === value) ?? + null; + + // The branch choice belongs to this screen: clear it when the section mounts + // and when it goes away, so a branch picked for one draft can never reach the + // next one. + useEffect(() => { + resetSelectedBranchOverrides(); + return resetSelectedBranchOverrides; + }, []); + return ( @@ -104,6 +132,8 @@ export function NewSessionRepositorySection({ /> )} + + {groups.map(group => ( {renderGroupCard(group.key, group.status)} ))} @@ -149,11 +179,13 @@ export function NewSessionRepositorySection({ function renderConnectCard(platform: RepositoryPlatform): ReactElement | null { const copy = PROVIDER_COPY[platform]; + const noteKey = connectNoteKey(platform); return ( {t(copy.connectTitle)} {t(copy.connectDescription)} + {noteKey ? {t(noteKey)} : null} + + ); + } + // Empty: the repository stays selected and the session starts on whatever + // the provider checks out — there is no override to offer. + if (branches.branches.length === 0) { + return renderNote(t('agentChat.newSession.branchEmpty')); + } + return renderTrigger(); + } + + function renderNote(message: string) { + return ( + + {message} + + ); + } + + function renderTrigger() { + // A provider can list branches without naming a default (a mirror with no + // HEAD, a repository whose default was deleted). The picker below still + // lists every branch, so the row asks for a choice rather than claiming + // the list is empty, and nothing is marked as the default. + const label = selectedBranch ?? t('agentChat.newSession.branchPlaceholder'); + const isDefault = selectedBranch !== null && selectedBranch === branches.defaultBranch; + return ( + + + {label} + + {isDefault ? ( + + {t('agentChat.newSession.branchDefault')} + + ) : null} + + + ); + } + + function openPicker() { + if (!repository || disabled) { + return; + } + // The keyboard belongs to the form; the sheet must not slide up over an + // open keyboard (the form keeps first responder across taps). + Keyboard.dismiss(); + branchPickerSlot.set(UNFENCED_ROUTE_KEY, { + branches: branches.branches, + defaultBranch: branches.defaultBranch, + selectedBranch, + onSelect: branch => { + // The provider default is stored as "no override", so the create body + // only carries `upstreamBranch` for a real, non-default choice. + setSelectedBranchOverride(repository, branch === branches.defaultBranch ? null : branch); + }, + }); + router.push('/(app)/agent-chat/branch-picker' as Href); + } +} diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 6578897c9d..a97a6ef3c9 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -736,7 +736,8 @@ export function SessionDetailContent({ if (kept.length === 0) { return base; } - return [...base, ...kept].toSorted((a, b) => { + // eslint-disable-next-line unicorn/no-array-sort -- Hermes does not implement Array.prototype.toSorted; the spread already copies so nothing shared is mutated + return [...base, ...kept].sort((a, b) => { if (a.info.id < b.info.id) { return -1; } diff --git a/apps/mobile/src/components/agents/session-pr-badge.test.ts b/apps/mobile/src/components/agents/session-pr-badge.test.ts index bd751d1a1f..263cf7a75f 100644 --- a/apps/mobile/src/components/agents/session-pr-badge.test.ts +++ b/apps/mobile/src/components/agents/session-pr-badge.test.ts @@ -243,7 +243,7 @@ describe('SessionPrBadge mounted', () => { expect(mocks.openExternalUrl).not.toHaveBeenCalled(); }); - it('opens the browser for a GitLab PR on press', async () => { + it('opens the in-app merge request route for a GitLab MR on press', async () => { const renderer = await renderBadge({ pr: pr({ platform: 'gitlab', @@ -255,11 +255,26 @@ describe('SessionPrBadge mounted', () => { const pressable = findHost(renderer.root, 'Pressable')[0]; pressable?.props.onPress(); - expect(mocks.openExternalUrl).toHaveBeenCalledWith( - 'https://gitlab.com/octocat/hello-world/-/merge_requests/42', - { label: 'pull request' } + expect(mocks.push).toHaveBeenCalledWith( + '/(app)/pr-review/gitlab/octocat/hello-world/42?instance=https%3A%2F%2Fgitlab.com' ); - expect(mocks.push).not.toHaveBeenCalled(); + expect(mocks.openExternalUrl).not.toHaveBeenCalled(); + }); + + it('opens the in-app pull request route for a Bitbucket PR on press', async () => { + const renderer = await renderBadge({ + pr: pr({ + platform: 'bitbucket', + url: 'https://bitbucket.org/acme/api/pull-requests/42', + }), + loading: false, + }); + + const pressable = findHost(renderer.root, 'Pressable')[0]; + pressable?.props.onPress(); + + expect(mocks.push).toHaveBeenCalledWith('/(app)/pr-review/bitbucket/acme/api/42'); + expect(mocks.openExternalUrl).not.toHaveBeenCalled(); }); it('opens the browser for a GitHub PR when the PR review flag is off', async () => { diff --git a/apps/mobile/src/components/agents/session-pr-badge.tsx b/apps/mobile/src/components/agents/session-pr-badge.tsx index bf335c7850..b557d3004f 100644 --- a/apps/mobile/src/components/agents/session-pr-badge.tsx +++ b/apps/mobile/src/components/agents/session-pr-badge.tsx @@ -93,7 +93,6 @@ export function SessionPrBadge(props: SessionPrBadgeProps) { } const target = resolveSessionPrTapTarget({ url: pr.url, - number: pr.number, }); if (target.kind === 'in-app') { router.push(target.href); diff --git a/apps/mobile/src/components/agents/use-new-session-creator.test.ts b/apps/mobile/src/components/agents/use-new-session-creator.test.ts index a31d48ae45..58a967248e 100644 --- a/apps/mobile/src/components/agents/use-new-session-creator.test.ts +++ b/apps/mobile/src/components/agents/use-new-session-creator.test.ts @@ -6,7 +6,11 @@ import TestRenderer, { act } from 'react-test-renderer'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type AgentMode } from '@/components/agents/mode-selector'; -import { type NewSessionRepository } from './new-session-repository-state'; +import { + type NewSessionRepository, + resetSelectedBranchOverrides, + setSelectedBranchOverride, +} from './new-session-repository-state'; import { useNewSessionCreator } from './use-new-session-creator'; import { clearDraft, flushDraft, loadDraft } from '@/lib/persist/drafts'; import { useFencedDraftLoad, useRemoteSpawnDraftCleanup } from '@/lib/persist/use-draft-load'; @@ -1238,3 +1242,111 @@ describe('useRemoteSpawnDraftCleanup remote-spawn clear', () => { expect(vi.mocked(flushDraft)).not.toHaveBeenCalled(); }); }); + +describe('useNewSessionCreator upstream branch', () => { + const githubRow: NewSessionRepository = { + platform: 'github', + fullName: 'owner/repo', + isPrivate: false, + }; + const gitlabRow: NewSessionRepository = { + platform: 'gitlab', + fullName: 'group/project', + isPrivate: true, + }; + const bitbucketRow: NewSessionRepository = { + platform: 'bitbucket', + fullName: 'workspace/repo', + isPrivate: true, + workspaceUuid: 'ws-1234', + repositoryUuid: 'repo-5678', + }; + + beforeEach(() => { + resetSelectedBranchOverrides(); + }); + + it('omits upstreamBranch when the provider default is in effect', async () => { + prepareSessionMutate.mockResolvedValue(sessionResult()); + const creator = runCreator({ selectedRepository: githubRow }); + + creator.promptRef.current = 'hello'; + await creator.createSessionFromDraft(); + + expect(prepareSessionMutate.mock.calls[0]?.[0]).not.toHaveProperty('upstreamBranch'); + }); + + it('sends the chosen branch for a GitHub row', async () => { + prepareSessionMutate.mockResolvedValue(sessionResult()); + setSelectedBranchOverride(githubRow, 'release/2.0'); + const creator = runCreator({ selectedRepository: githubRow }); + + creator.promptRef.current = 'hello'; + await creator.createSessionFromDraft(); + + expect(prepareSessionMutate.mock.calls[0]?.[0]).toMatchObject({ + githubRepo: 'owner/repo', + upstreamBranch: 'release/2.0', + }); + }); + + it('sends the chosen branch for a GitLab row', async () => { + prepareSessionMutate.mockResolvedValue(sessionResult()); + setSelectedBranchOverride(gitlabRow, 'feature/x'); + const creator = runCreator({ selectedRepository: gitlabRow }); + + creator.promptRef.current = 'hello'; + await creator.createSessionFromDraft(); + + expect(prepareSessionMutate.mock.calls[0]?.[0]).toMatchObject({ + gitlabProject: 'group/project', + upstreamBranch: 'feature/x', + }); + }); + + it('sends the chosen branch for a Bitbucket row', async () => { + prepareSessionMutate.mockResolvedValue(sessionResult()); + setSelectedBranchOverride(bitbucketRow, 'develop'); + const creator = runCreator({ organizationId: 'org-1', selectedRepository: bitbucketRow }); + + creator.promptRef.current = 'hello'; + await creator.createSessionFromDraft(); + + expect(prepareSessionMutate.mock.calls[0]?.[0]).toMatchObject({ + bitbucketRepo: { fullName: 'workspace/repo' }, + upstreamBranch: 'develop', + }); + }); + + it('never carries a branch chosen for another repository', async () => { + prepareSessionMutate.mockResolvedValue(sessionResult()); + setSelectedBranchOverride(gitlabRow, 'feature/x'); + const creator = runCreator({ selectedRepository: githubRow }); + + creator.promptRef.current = 'hello'; + await creator.createSessionFromDraft(); + + expect(prepareSessionMutate.mock.calls[0]?.[0]).not.toHaveProperty('upstreamBranch'); + }); + + it('keeps the retry fingerprint repository-scoped when the branch changes', async () => { + prepareSessionMutate.mockResolvedValue(sessionResult()); + const first = runCreator({ selectedRepository: githubRow }); + first.promptRef.current = 'hello'; + await first.createSessionFromDraft(); + const defaultBranchFingerprint = outboxMock.writeSafeRetry.mock.calls[0]?.[0].fingerprint; + + setSelectedBranchOverride(githubRow, 'release/2.0'); + const second = runCreator({ selectedRepository: githubRow }); + second.promptRef.current = 'hello'; + await second.createSessionFromDraft(); + const overrideFingerprint = outboxMock.writeSafeRetry.mock.calls[1]?.[0].fingerprint; + + // Same intent, same retry key: a branch change must not fork the safe-retry + // row, or one submit could replay as two sessions. + expect(overrideFingerprint).toBe(defaultBranchFingerprint); + expect(prepareSessionMutate.mock.calls[1]?.[0]).toMatchObject({ + upstreamBranch: 'release/2.0', + }); + }); +}); diff --git a/apps/mobile/src/components/agents/use-new-session-creator.ts b/apps/mobile/src/components/agents/use-new-session-creator.ts index d83e1ff960..2c2a804948 100644 --- a/apps/mobile/src/components/agents/use-new-session-creator.ts +++ b/apps/mobile/src/components/agents/use-new-session-creator.ts @@ -7,6 +7,7 @@ import { toast } from 'sonner-native'; import { i18n } from '@/i18n'; import { type AgentMode } from '@/components/agents/mode-selector'; import { + getSelectedBranchOverride, type NewSessionRepository, type RepositoryPlatform, } from '@/components/agents/new-session-repository-state'; @@ -50,6 +51,8 @@ type PrepareSessionInput = { githubRepo?: string; gitlabProject?: string; bitbucketRepo?: { fullName: string; workspaceUuid: string; repositoryUuid: string }; + /** The chosen non-default branch; omitted, the provider's default is checked out. */ + upstreamBranch?: string; autoCommit: boolean; autoInitiate: boolean; operationKey: string; @@ -327,9 +330,16 @@ function resolveRepoFingerprint(repository: NewSessionRepository | null): { /** * Write exactly one repository field into the create body, matching the - * selected row's platform. Bitbucket requires workspace + run ids, so it - * contributes nothing when those are missing (which cannot happen for a row - * that came from `listBitbucketRepositories`). + * selected row's platform, plus the branch the user picked for that exact + * repository. Bitbucket requires workspace + run ids, so it contributes + * nothing when those are missing (which cannot happen for a row that came + * from `listBitbucketRepositories`). + * + * The branch is read by repository identity, so a branch chosen for another + * repository can never ride along; only a non-default choice is stored, so an + * unset `upstreamBranch` means "check out the provider's own default". It is + * deliberately absent from the retry fingerprint: the retry key stays + * repository-scoped, and changing the branch must not fork it. */ function setRepositoryField( input: PrepareSessionInput, @@ -340,10 +350,12 @@ function setRepositoryField( } if (repository.platform === 'github') { input.githubRepo = repository.fullName; + setUpstreamBranch(input, repository); return; } if (repository.platform === 'gitlab') { input.gitlabProject = repository.fullName; + setUpstreamBranch(input, repository); return; } if (repository.workspaceUuid && repository.repositoryUuid) { @@ -352,5 +364,14 @@ function setRepositoryField( workspaceUuid: repository.workspaceUuid, repositoryUuid: repository.repositoryUuid, }; + setUpstreamBranch(input, repository); + } +} + +/** Carry the branch only when a repository field was written for it. */ +function setUpstreamBranch(input: PrepareSessionInput, repository: NewSessionRepository): void { + const branch = getSelectedBranchOverride(repository); + if (branch !== null) { + input.upstreamBranch = branch; } } diff --git a/apps/mobile/src/components/picker-sheet.tsx b/apps/mobile/src/components/picker-sheet.tsx index 42ab734c2b..f4c5b8e8af 100644 --- a/apps/mobile/src/components/picker-sheet.tsx +++ b/apps/mobile/src/components/picker-sheet.tsx @@ -68,7 +68,14 @@ export function PickerSheet({ {headerContent} {scrollable && !expired ? ( - {body} + // keyboardShouldPersistTaps keeps a first tap on a row working while + // a picker's search field holds the keyboard open. + + {body} + ) : ( body )} diff --git a/apps/mobile/src/components/pr-review/composer-inline-error.tsx b/apps/mobile/src/components/pr-review/composer-inline-error.tsx index 73c9500a8b..a5fa43772d 100644 --- a/apps/mobile/src/components/pr-review/composer-inline-error.tsx +++ b/apps/mobile/src/components/pr-review/composer-inline-error.tsx @@ -90,7 +90,7 @@ export function useComposerInlineError(error: unknown, isEdit: boolean) { })(); return; } - const display = mutationErrorDisplay('composer', classification, error); + const display = mutationErrorDisplay('composer', classification, { rawError: error }); setInlineError(display.message); setInlineErrorKind(display.kind); setInlineErrorIsLocal(false); diff --git a/apps/mobile/src/components/pr-review/diff/diff-line.mounted.test.tsx b/apps/mobile/src/components/pr-review/diff/diff-line.mounted.test.tsx new file mode 100644 index 0000000000..840eac4046 --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/diff-line.mounted.test.tsx @@ -0,0 +1,108 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as pr-diff-hunk-rows.test.tsx) */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import '@/i18n'; +import { DiffLine } from './diff-line'; +import { type ParsedDiffLine } from '@/lib/pr-review/diff/parse-patch'; + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + Text: 'RNText', + View: 'View', +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ + background: '#FFFFFF', + foreground: '#111111', + good: '#0a0', + destructive: '#d00', + mutedForeground: '#777777', + }), +})); + +function line(overrides: Partial = {}): ParsedDiffLine { + return { + type: 'context', + oldLine: 12, + newLine: 12, + text: 'const value = computeSomething(x);', + noNewlineAtEndOfFile: false, + ...overrides, + }; +} + +/** Mount a DiffLine inside act, so subscription updates stay inside it. */ +function mountLine(props: { + line: ParsedDiffLine; + language: string | null; + keyId: string; +}): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | null } = { current: null }; + act(() => { + ref.current = TestRenderer.create(createElement(DiffLine, props)); + }); + const created = ref.current; + if (created === null) { + throw new Error('the diff line did not render'); + } + return created; +} + +/** The row is the only `flex-row items-stretch` View in a DiffLine. */ +function findRow(renderer: TestRenderer.ReactTestRenderer): TestRenderer.ReactTestInstance { + const rows = renderer.root.findAll( + node => + node.type === ('View' as never) && + typeof node.props.className === 'string' && + node.props.className.includes('flex-row items-stretch') + ); + const [row] = rows; + if (rows.length !== 1 || row === undefined) { + throw new Error(`expected exactly one diff row, found ${rows.length}`); + } + return row; +} + +describe('DiffLine gutter alignment', () => { + // The row is `flex-row items-stretch`, so the gutter View stretches to the + // row's full height. A long code line wraps and makes the row several + // visual lines tall; the line number must sit on the FIRST visual line — + // aligned with the code's first line via the same top padding the code + // container uses — never centered onto a later visual line. + it('aligns the gutter number with the start of the row on a wrapped line', () => { + const renderer = mountLine({ + line: line({ + text: 'const wrappedValue = someVeryLongExpression(thatDoesNotFitOnOneLine, atPhoneWidth) + trailingOperand;', + }), + language: null, + keyId: 'line-12', + }); + + const row = findRow(renderer); + const [gutter, code] = row.props.children as [ + TestRenderer.ReactTestInstance, + TestRenderer.ReactTestInstance, + ]; + + expect(gutter.props.className).toContain('justify-start'); + expect(gutter.props.className).not.toContain('justify-center'); + expect((gutter.props.style as { paddingTop: number }).paddingTop).toBe(2); + // The code container pads by the same amount, so the gutter's first line + // and the code's first visual line share one baseline. + expect((code.props.style as { paddingVertical: number }).paddingVertical).toBe(2); + }); + + it('keeps the same alignment for add and delete rows', () => { + for (const type of ['add', 'del', 'context'] as const) { + const renderer = mountLine({ line: line({ type }), language: null, keyId: `k-${type}` }); + const row = findRow(renderer); + const [gutter] = row.props.children as [ + TestRenderer.ReactTestInstance, + TestRenderer.ReactTestInstance, + ]; + expect(gutter.props.className).toContain('justify-start'); + } + }); +}); diff --git a/apps/mobile/src/components/pr-review/diff/diff-line.tsx b/apps/mobile/src/components/pr-review/diff/diff-line.tsx index 07001afad6..795ab003fa 100644 --- a/apps/mobile/src/components/pr-review/diff/diff-line.tsx +++ b/apps/mobile/src/components/pr-review/diff/diff-line.tsx @@ -121,6 +121,11 @@ function DiffLineImpl({ line, language, onTap, isSelected }: Readonly - + {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic theme color + mono font for gutter */} { }); expect(routerPush).toHaveBeenCalledTimes(1); - expect(routerPush).toHaveBeenCalledWith( - expect.objectContaining({ - pathname: '/(app)/pr-review/[owner]/[repo]/[number]/file-navigator', - params: { owner: 'octocat', repo: 'hello', number: 7 }, - }) - ); + // GitHub keeps the existing three-segment sibling; `providerPrChildRoutePath` + // resolves the ref to a string href (locked by provider-pr-ref.test.ts). + expect(routerPush).toHaveBeenCalledWith('/(app)/pr-review/octocat/hello/7/file-navigator'); }); }); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx index 4ac1853567..dea2e10338 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx @@ -22,6 +22,7 @@ import { formatNumber } from '@/lib/format'; import { useIsTablet } from '@/lib/hooks/use-is-tablet'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type DiffViewMode } from '@/lib/pr-review/diff/pr-diff-list-items'; +import { providerPrChildRoutePath, useProviderPrScope } from '@/lib/pr-review/provider-pr-ref'; import { cn } from '@/lib/utils'; type PrDiffFileListHeaderProps = { @@ -35,8 +36,6 @@ type PrDiffFileListHeaderProps = { readonly onViewModeChange: (mode: DiffViewMode) => void; }; -const FILE_NAVIGATOR_PATH = '/(app)/pr-review/[owner]/[repo]/[number]/file-navigator' as const; - export function PrDiffFileListHeader({ owner, repo, @@ -66,10 +65,14 @@ export function PrDiffFileListHeader({ } : undefined; - const navigatorHref = useMemo( - () => ({ pathname: FILE_NAVIGATOR_PATH, params: { owner, repo, number } }), - [owner, repo, number] - ); + // The sheet is a sibling of the screen it was opened from, so its href is + // built from the live scope: a GitHub ref keeps the original + // `[owner]/[repo]/[number]/file-navigator` path, a GitLab or Bitbucket ref + // opens the sheet inside the provider layout — the only place its scope is + // published, and therefore the only place the sheet can query the right + // provider. + const { ref } = useProviderPrScope({ owner, repo, number }); + const navigatorHref = useMemo(() => providerPrChildRoutePath(ref, 'file-navigator'), [ref]); const handleOpenNavigator = useCallback(() => { router.push(navigatorHref); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.test.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.test.tsx index 2787085527..d8e97641f0 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.test.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.test.tsx @@ -4,11 +4,17 @@ import { type RefreshControlProps } from 'react-native'; import TestRenderer, { act } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ProviderPrScopeProvider } from '@/lib/pr-review/provider-pr-ref'; + import { PrReviewFileList } from './pr-diff-file-list'; -import { prDiffListBottomPadding } from '@/lib/pr-review/diff/pr-diff-list-bottom-padding'; const insetsState = vi.hoisted(() => ({ top: 0, bottom: 0, left: 0, right: 0 })); +// Records every (ref, headSha) the list hands to the viewed-files hook, so +// the provider-scoped keying (s6, identity rule 17) is proven at the call +// site rather than only in the store's unit tests. +const viewedFilesCalls = vi.hoisted(() => [] as unknown[][]); + const listQueryState = vi.hoisted(() => ({ query: { isLoading: false, @@ -90,7 +96,10 @@ vi.mock('@/lib/pr-review/diff/use-pr-diff-context-loader', () => ({ })); vi.mock('@/lib/pr-review/diff/pr-review-file-list-state', () => ({ usePrReviewFileListQuery: () => listQueryState, - usePrReviewViewedFiles: () => ({ isViewed: () => false, toggle: vi.fn(), isLoading: false }), + usePrReviewViewedFiles: (...args: unknown[]) => { + viewedFilesCalls.push(args); + return { isViewed: () => false, toggle: vi.fn(), isLoading: false }; + }, useFetchToCompletion: () => ({ run: vi.fn(), isRunning: false, @@ -131,6 +140,24 @@ function mountList(changedFiles = BASE_PROPS.changedFiles): TestRenderer.ReactTe return renderer; } +function mountListInScope( + ref: Parameters[0]['value']['ref'] +): TestRenderer.ReactTestRenderer { + const holder: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + holder.current = TestRenderer.create( + + + + ); + }); + const renderer = holder.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + function bottomPaddedViews( renderer: TestRenderer.ReactTestRenderer ): TestRenderer.ReactTestInstance[] { @@ -162,14 +189,14 @@ function flashListProps(renderer: TestRenderer.ReactTestRenderer): { }; } -describe('PrReviewFileList full-body states', () => { - beforeEach(() => { - insetsState.bottom = 0; - insetsState.left = 0; - insetsState.right = 0; - resetState(); - }); +beforeEach(() => { + insetsState.bottom = 0; + insetsState.left = 0; + insetsState.right = 0; + resetState(); +}); +describe('PrReviewFileList full-body states', () => { it('centers the reconnect notice without local bottom padding', () => { listQueryState.firstPageErrorState = { kind: 'reconnect' }; const renderer = mountList(); @@ -224,34 +251,80 @@ describe('PrReviewFileList full-body states', () => { }); }); -describe('PrReviewFileList content container side insets (landscape)', () => { +// The comment composer and the review-submit sheet are route siblings on +// every provider (s6): the write bar renders on a GitLab MR / Bitbucket PR +// too, and the bar carries the provider ref so it pushes the sheet inside +// the ref's own route — never the GitHub sibling. +describe('PrReviewFileList write affordances per provider', () => { beforeEach(() => { - insetsState.bottom = 0; - insetsState.left = 0; - insetsState.right = 0; - resetState(); listQueryState.files = [{ path: 'src/file.ts' }]; + viewedFilesCalls.length = 0; }); - it('keeps the current content container style at zero portrait insets', () => { + it('keeps the write bar on a GitHub pull request', () => { const renderer = mountList(); + const bar = renderer.root.find(node => String(node.type) === 'PrDiffFloatingActions'); + expect(bar.props.prRef).toBeUndefined(); + }); - expect(flashListProps(renderer).contentContainerStyle).toEqual({ - paddingBottom: prDiffListBottomPadding(null), - paddingLeft: 0, - paddingRight: 0, - }); + // The viewed set must be keyed by the live provider ref (s6, identity + // rule 17): the store folds `providerPrRefKey` into the key only when the + // call site hands it a ref, so the bare triple would silently collide. + it('keys the viewed set by the live ref, never the bare triple', () => { + mountList(); + expect(viewedFilesCalls[0]).toEqual([ + { platform: 'github', owner: 'octocat', repo: 'hello-world', number: 7 }, + 'sha', + ]); + mountListInScope({ platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 12 }); + expect(viewedFilesCalls[1]).toEqual([ + { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 12 }, + 'sha', + ]); + mountListInScope({ platform: 'bitbucket', workspace: 'acme', repoSlug: 'api', prId: 42 }); + expect(viewedFilesCalls[2]).toEqual([ + { platform: 'bitbucket', workspace: 'acme', repoSlug: 'api', prId: 42 }, + 'sha', + ]); }); - it('adds the landscape side insets to the content container style', () => { - insetsState.left = 47; - insetsState.right = 59; - const renderer = mountList(); + it.each([ + { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 12 }, + { platform: 'bitbucket', workspace: 'acme', repoSlug: 'api', prId: 42 }, + ] as const)('keeps the write bar on a $platform request with the provider ref', prRef => { + const renderer = mountListInScope(prRef); + const bar = renderer.root.find(node => String(node.type) === 'PrDiffFloatingActions'); + expect(bar.props.prRef).toEqual(prRef); + }); + + it('keeps the small footer gap under a provider diff list too', () => { + const githubPadding = flashListProps(mountList()).contentContainerStyle?.paddingBottom; + const gitlabPadding = flashListProps( + mountListInScope({ platform: 'gitlab', projectPath: 'group/repo', mrIid: 12 }) + ).contentContainerStyle?.paddingBottom; + // The bar is an in-flow footer below the list (spot check e3), so no + // row can ever scroll under it; the list only keeps a 12-point gap + // between its last row and the footer's top edge, on every provider. + expect(githubPadding).toBe(12); + expect(gitlabPadding).toBe(12); + }); +}); + +describe('PrReviewFileList content container side insets (landscape)', () => { + beforeEach(() => { + listQueryState.files = [{ path: 'src/file.ts' }]; + }); - expect(flashListProps(renderer).contentContainerStyle).toEqual({ - paddingBottom: prDiffListBottomPadding(null), - paddingLeft: 47, - paddingRight: 59, + it.each([ + { left: 0, right: 0, pl: 0, pr: 0 }, + { left: 47, right: 59, pl: 47, pr: 59 }, + ] as const)('pads the content container (left=$left right=$right)', ({ left, right, pl, pr }) => { + insetsState.left = left; + insetsState.right = right; + expect(flashListProps(mountList()).contentContainerStyle).toEqual({ + paddingBottom: 12, + paddingLeft: pl, + paddingRight: pr, }); }); }); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx index 84694be5e0..4acca5e778 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx @@ -12,8 +12,9 @@ // * S7a adds diff-line selection: tapping a line runs the pure // `selectLine` reducer; the result is mirrored into the // `diff-selection-bridge` (so the comment composer can read it on -// mount) and a floating action bar (`PrDiffFloatingActions`) -// hosts the "Comment" and "Finish review" affordances. +// mount) and a footer action bar (`PrDiffFloatingActions`) rendered +// in-flow below the list hosts the "Comment" and "Finish review" +// affordances. // // Cold first paint: FlashList mounts only after the first page of files is // present. The first-load waiting state is a plain skeleton outside the list @@ -41,6 +42,8 @@ import { } from '@/components/pr-review/diff/pr-diff-file-list-header'; import { PrDiffFileListLoading } from '@/components/pr-review/diff/pr-diff-file-list-loading'; import { PrDiffFloatingActions } from '@/components/pr-review/diff/pr-diff-floating-actions'; +import { usePrDiffStateCopy } from '@/components/pr-review/diff/pr-diff-state-copy'; +import { useProviderPrScope } from '@/lib/pr-review/provider-pr-ref'; import { useDiffRenderItem } from '@/components/pr-review/diff/pr-diff-file-list-render'; import { useDiffSelection } from '@/components/pr-review/diff/use-diff-selection'; import { EmptyFilesView, TabStateMessage } from '@/components/pr-review/diff/pr-diff-rows'; @@ -91,7 +94,12 @@ export function PrReviewFileList({ number, enabled: true, }); - const viewed = usePrReviewViewedFiles({ owner, repo, number }, headSha); + // The live provider scope: the viewed set is keyed by the ref (s6, + // identity rule 17), so a GitLab MR and a same-numbered GitHub PR — or + // one project on two GitLab instances — never share a set. On the GitHub + // route the fallback ref is the triple itself, keeping the legacy bytes. + const scope = useProviderPrScope({ owner, repo, number }); + const viewed = usePrReviewViewedFiles(scope.ref, headSha); const fetchToCompletion = useFetchToCompletion(query, changedFiles); const [expanded, setExpanded] = useState>({}); @@ -121,21 +129,17 @@ export function PrReviewFileList({ [owner, repo, number] ); - // Measured floating-bar height (null until the first layout event). - const [barHeight, setBarHeight] = useState(null); + // The write bar renders on every provider (s6): its two routes — the + // comment composer and the review-submit sheet — are siblings of the + // GitHub route AND of the provider route, so the bar pushes the sheet + // inside the scope its queries run under. The bar is an in-flow footer + // below the list (spot check e3), so the list keeps only the fixed footer + // gap plus the landscape side insets (which keep rows clear of the sensor + // housing). + const listContentStyle = usePrDiffListContentPadding(null); - // Stable callback: ignore sub-one-point noise to avoid unnecessary - // re-renders. Layout events can fire with fractional-pixel deltas. - const handleHeightChange = useCallback((height: number) => { - setBarHeight(prev => { - if (prev !== null && Math.abs(prev - height) < 1) { - return prev; - } - return height; - }); - }, []); - - const contentContainerStyle = usePrDiffListContentPadding(barHeight); + // Which provider's words the terminal and empty states use. + const copy = usePrDiffStateCopy({ owner, repo, number }); const viewedCount = useMemo(() => { let count = 0; @@ -259,19 +263,11 @@ export function PrReviewFileList({ if (files.length === 0) { if (firstPageErrorState?.kind === 'not-found') { - return ( - - ); + return ; } if (firstPageErrorState?.kind === 'permission') { return ( - + ); } if (firstPageErrorState?.kind === 'reconnect') { @@ -298,6 +294,7 @@ export function PrReviewFileList({ return ( 0 ? ( @@ -354,7 +351,7 @@ export function PrReviewFileList({ } }} onEndReachedThreshold={0.5} - contentContainerStyle={contentContainerStyle} + contentContainerStyle={listContentStyle} ItemSeparatorComponent={null} /> )} @@ -362,10 +359,10 @@ export function PrReviewFileList({ owner={owner} repo={repo} number={number} + prRef={scope.ref.platform === 'github' ? undefined : scope.ref} viewMode={effectiveViewMode} selection={selection} onClearSelection={clearSelection} - onHeightChange={handleHeightChange} /> diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.test.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.test.tsx index d8c25eb1bd..e51f73d7f7 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.test.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.test.tsx @@ -16,6 +16,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import '@/i18n'; import { PrDiffFileNavigator } from '@/components/pr-review/diff/pr-diff-file-navigator'; import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-types'; +import { type ProviderPrRef, ProviderPrScopeProvider } from '@/lib/pr-review/provider-pr-ref'; import { renderWithProviders } from '@/test/render-with-providers'; // ── Hoisted mocks ────────────────────────────────────────────────────────── @@ -31,6 +32,11 @@ const rowRenders = vi.hoisted( () => [] as { path: string; onSelect: () => void; onToggleViewed: () => void }[] ); +// Records every (ref, headSha) the navigator hands to the viewed-files hook, +// so the provider-scoped keying (s6, identity rule 17) is proven at the call +// site rather than only in the store's unit tests. +const viewedFilesCalls = vi.hoisted(() => [] as unknown[][]); + // Captures the latest FlashList props so tests can read `onEndReached`. const flashListProps = vi.hoisted(() => ({ current: null as null | Record })); @@ -153,7 +159,10 @@ let fetchAllResult: FetchAllResult = { vi.mock('@/lib/pr-review/diff/pr-review-file-list-state', () => ({ usePrReviewFileListQuery: () => listQueryResult, - usePrReviewViewedFiles: () => viewedResult, + usePrReviewViewedFiles: (...args: unknown[]) => { + viewedFilesCalls.push(args); + return viewedResult; + }, useFetchToCompletion: () => fetchAllResult, })); @@ -571,3 +580,60 @@ describe('PrDiffFileNavigator list bottom inset (plan §6)', () => { expect(listContentStyle()).toEqual({ paddingBottom: 66, paddingTop: 8 }); }); }); + +// s6 (identity rule 17): the sheet toggling a file here and the diff list +// behind it share the viewed store, so both must key it by the LIVE provider +// ref. A bare triple would silently collide across providers and GitLab +// instances; the store only folds the collision-free key when it receives +// the ref. +describe('PrDiffFileNavigator viewed-set provider keying (s6)', () => { + const GITLAB_REF: ProviderPrRef = { + platform: 'gitlab', + projectPath: 'group/sub/repo', + mrIid: 12, + }; + + beforeEach(() => { + viewedFilesCalls.length = 0; + listQueryResult = { + query: { + isLoading: false, + isFetching: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: fetchNextPageMock, + refetch: vi.fn(), + }, + files: [makeFile('src/a.ts')], + firstPageErrorState: null, + laterPageError: false, + }; + viewedResult = { isViewed: () => false, toggle: vi.fn(() => undefined), isLoading: false }; + fetchAllResult = { + run: fetchAllRunMock, + isRunning: false, + loadedFiles: 0, + totalFiles: null, + error: null, + }; + }); + + it('keys the viewed set by the provider ref under the provider scope', async () => { + await renderWithProviders( + + + + ); + + expect(viewedFilesCalls[0]).toEqual([GITLAB_REF, 'sha']); + }); + + it('falls back to the GitHub ref triple on the GitHub route', async () => { + await renderWithProviders(); + + expect(viewedFilesCalls[0]).toEqual([ + { platform: 'github', owner: 'octocat', repo: 'hello-world', number: 7 }, + 'sha', + ]); + }); +}); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx index 1b68b7e368..8b041df532 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx @@ -43,6 +43,7 @@ import { } from '@/lib/pr-review/diff/pr-review-file-list-state'; import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-types'; import { filterNavigatorFiles } from '@/lib/pr-review/diff/navigator-file-filter'; +import { useProviderPrScope } from '@/lib/pr-review/provider-pr-ref'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; // Memoized row so recycled cells do not re-render on every keystroke: `file` @@ -108,7 +109,11 @@ export function PrDiffFileNavigator({ number, enabled: true, }); - const viewed = usePrReviewViewedFiles({ owner, repo, number }, headSha); + // The viewed set is keyed by the live provider ref (s6, identity rule 17), + // so the sheet toggling a file here and the diff list behind it read and + // write the SAME provider-scoped set — and never a same-numbered PR's. + const scope = useProviderPrScope({ owner, repo, number }); + const viewed = usePrReviewViewedFiles(scope.ref, headSha); const fetchAll = useFetchToCompletion(query, changedFiles); const hasActiveSearch = searchRef.current.trim().length > 0; diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.backdrop.test.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.backdrop.test.tsx new file mode 100644 index 0000000000..7338b83730 --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.backdrop.test.tsx @@ -0,0 +1,102 @@ +// Spot check e2-expand.png: the Finish review island floated over the +// unified diff with deleted-line text still visible around and below the +// button. The card was opaque but the bar's padding ring was not, so diff +// rows scrolled under it showed through. The bar container itself must +// carry the screen background and swallow touches in that ring. +// (Extracted from pr-diff-floating-actions.test.tsx to keep that file +// inside the max-lines budget.) + +import * as React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import '@/i18n'; +import type * as ReactI18next from 'react-i18next'; +import { PrDiffFloatingActions } from './pr-diff-floating-actions'; +import { type SelectionState } from '@/lib/pr-review/diff-selection'; + +vi.mock('react-i18next', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => { + const i18n = actual.getI18n(); + return { t: i18n.t.bind(i18n), i18n }; + }, + }; +}); + +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock('react-native', () => ({ + View: 'View', + Platform: { OS: 'ios' }, +})); + +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})); + +vi.mock('@/components/ui/icons', () => ({ + MessageCirclePlus: () => null, +})); + +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ + primaryForeground: '#FFFFFF', + foreground: '#000000', + mutedForeground: '#6F6A61', + }), +})); + +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/lib/pr-review/diff-selection-bridge', () => ({ + clearDiffSelection: vi.fn(), +})); + +vi.mock('@/lib/pr-review/pending-review-provider', () => ({ + usePendingReview: () => ({ + items: [], + addComment: vi.fn(() => undefined), + updateComment: vi.fn(() => undefined), + removeComment: vi.fn(() => undefined), + clear: vi.fn(() => undefined), + }), +})); + +const baseProps = { + owner: 'octocat', + repo: 'hello', + number: 7, + viewMode: 'unified' as const, + selection: null as SelectionState | null, + onClearSelection: vi.fn(), +}; + +function renderBar(): React.ReactElement { + // eslint-disable-next-line new-cap -- plain function component, no hooks state needed for the container props + return PrDiffFloatingActions(baseProps); +} + +describe('PrDiffFloatingActions opaque backdrop (spot check e2)', () => { + it('paints the bar container with the screen background and swallows touches', () => { + // With a transparent container the diff rows scrolled under the bar + // stayed visible around and below the button. The container carries the + // screen background, and the removed `pointerEvents="box-none"` means a + // tap in the padding ring can never reach a diff row hidden behind it. + const props = renderBar().props as { className?: string; pointerEvents?: string }; + expect(props.pointerEvents).toBeUndefined(); + expect((props.className ?? '').split(' ')).toContain('bg-background'); + }); + + it('keeps the action card on the same background inside the bar', () => { + const card = (renderBar().props as { children?: React.ReactElement }).children; + if (!card) { + throw new Error('floating action card not found'); + } + const classes = (card.props as { className?: string }).className ?? ''; + expect(classes.split(' ')).toContain('bg-background'); + }); +}); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.badge.mounted.test.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.badge.mounted.test.tsx new file mode 100644 index 0000000000..381fc20647 --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.badge.mounted.test.tsx @@ -0,0 +1,252 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as pr-diff-file-list.test.tsx) */ +// Spot check e1-select-line / e1-line1-comment: the Finish review count badge +// rode the label's top-right corner (`absolute -right-2.5 -top-2.5`), so the +// opaque pill drew over the last glyphs of the label. The earlier repairs +// pinned the bar's container and footer classes, never the badge's own +// placement inside the button, so the overlap survived them. This file mounts +// the real `PrDiffFloatingActions` inside the real `Button` (the composed +// render path the e1 screenshots show: a GitLab MR, a line selected so the +// Comment row is up, a non-empty pending queue) and pins what makes the +// overlap structurally impossible: the badge is an in-flow sibling AFTER the +// label in the button's `flex-row items-center justify-center gap-2` line, +// no node in the whole tree is absolute, and a two-digit count behaves the +// same. Mutation-inversion gate: re-wrapping the label in a `relative` View +// with an `absolute` badge must fail tests 1–3. +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import '@/i18n'; +import type * as ReactI18next from 'react-i18next'; +import { type PendingReviewItem } from '@/lib/pr-review/pending-review-provider'; +import { type ProviderPrRef } from '@/lib/pr-review/provider-pr-ref'; +import { type SelectionState } from '@/lib/pr-review/diff-selection'; +import { PrDiffFloatingActions } from './pr-diff-floating-actions'; + +vi.mock('react-i18next', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => { + const i18n = actual.getI18n(); + return { t: i18n.t.bind(i18n), i18n }; + }, + }; +}); + +const insets = vi.hoisted(() => ({ top: 0, bottom: 0, left: 0, right: 0 })); + +const pendingState = vi.hoisted((): { items: PendingReviewItem[] } => ({ items: [] })); + +// The real Button and the real Text mount against these host stubs, so the +// button's own `flex-row items-center justify-center gap-2` classes — the gap +// that separates label from badge — are the ones under test. +vi.mock('react-native', () => ({ + View: 'View', + Pressable: 'Pressable', + ActivityIndicator: 'ActivityIndicator', + Text: 'RNText', + Platform: { OS: 'ios' }, + I18nManager: { isRTL: false, doLeftAndRightSwapInRTL: false }, +})); +vi.mock('@rn-primitives/slot', () => ({ Text: 'SlotText', View: 'SlotView' })); +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => insets, +})); +vi.mock('@/components/ui/icons', () => ({ + MessageCirclePlus: () => null, +})); +// The real Button reaches the UI spinner through its loading arm; the real +// spinner pulls the motion policy (expo-battery), which this harness does not +// mount. The badge placement under test never renders the spinner. +vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ + primaryForeground: '#FFFFFF', + foreground: '#000000', + mutedForeground: '#6F6A61', + }), +})); +vi.mock('@/lib/pr-review/diff-selection-bridge', () => ({ + clearDiffSelection: vi.fn(), +})); +vi.mock('@/lib/pr-review/pending-review-provider', () => ({ + usePendingReview: () => ({ + items: pendingState.items, + addComment: vi.fn(() => undefined), + updateComment: vi.fn(() => undefined), + removeComment: vi.fn(() => undefined), + clear: vi.fn(() => undefined), + }), +})); + +const GITLAB_REF: ProviderPrRef = { + platform: 'gitlab', + projectPath: 'group/sub/repo', + mrIid: 12, +}; + +// The e1 moment: a README line is selected, so the bar shows the selection +// row (Comment + Clear) above the Finish review button. +const SELECTION: SelectionState = { + path: 'README.md', + side: 'RIGHT', + hunkKey: 'README.md:0', + startLine: 5, + line: 5, + selectedText: '- old readme line', +}; + +function makeItem(index: number): PendingReviewItem { + return { + id: `id-${index}`, + path: 'README.md', + side: 'RIGHT', + line: index + 1, + body: 'comment', + commitSha: 'head-1', + }; +} + +function classesOf(node: TestRenderer.ReactTestInstance): string[] { + return typeof node.props.className === 'string' ? node.props.className.split(' ') : []; +} + +function styleOf(node: TestRenderer.ReactTestInstance): Record { + const style = node.props.style; + return style != null && typeof style === 'object' && !Array.isArray(style) + ? (style as Record) + : {}; +} + +/** The instance children of a node, dropping raw text nodes. */ +function instanceChildren(node: TestRenderer.ReactTestInstance): TestRenderer.ReactTestInstance[] { + return node.children.filter( + (child): child is TestRenderer.ReactTestInstance => typeof child !== 'string' + ); +} + +/** Resolve a child to its rendered host node: the label mounts as the real + * Text composite, so the button's child is the composite, not the RNText. */ +function hostRoot(node: TestRenderer.ReactTestInstance): TestRenderer.ReactTestInstance { + let current = node; + while (typeof current.type !== 'string') { + const [first] = instanceChildren(current); + if (!first) { + throw new Error('composite rendered nothing'); + } + current = first; + } + return current; +} + +/** The instance child at `index`, or a thrown error naming the tree. */ +function childAt( + node: TestRenderer.ReactTestInstance, + index: number +): TestRenderer.ReactTestInstance { + const kids = instanceChildren(node); + const kid = kids[index]; + if (!kid) { + throw new Error(`expected a child at index ${index}, got ${kids.length} children`); + } + return kid; +} + +function hostText(node: TestRenderer.ReactTestInstance): string { + return node.children.filter((child): child is string => typeof child === 'string').join(''); +} + +function mountBar(pendingCount: number): TestRenderer.ReactTestRenderer { + pendingState.items = Array.from({ length: pendingCount }, (_, index) => makeItem(index)); + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create( + undefined)} + /> + ); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +function finishReviewButton( + renderer: TestRenderer.ReactTestRenderer +): TestRenderer.ReactTestInstance { + return renderer.root.find( + node => String(node.type) === 'Pressable' && node.props.accessibilityLabel === 'Finish review' + ); +} + +describe('Finish review count badge placement (spot check e1)', () => { + it('lays the badge out in-flow after the label, never over it', () => { + const renderer = mountBar(3); + const button = finishReviewButton(renderer); + const buttonClasses = classesOf(button); + // The button is the row that spaces label and badge apart. + expect(buttonClasses).toContain('flex-row'); + expect(buttonClasses).toContain('items-center'); + expect(buttonClasses).toContain('justify-center'); + expect(buttonClasses).toContain('gap-2'); + + const kids = instanceChildren(button); + expect(kids).toHaveLength(2); + const label = hostRoot(childAt(button, 0)); + expect(String(label.type)).toBe('RNText'); + expect(hostText(label)).toBe('Finish review'); + const badge = childAt(button, 1); + expect(String(badge.type)).toBe('View'); + expect(classesOf(badge)).toContain('rounded-full'); + // The defect: the badge was anchored to the label's corner with negative + // offsets, so it drew over the last glyphs. In-flow it cannot. + expect(classesOf(badge)).not.toContain('absolute'); + expect(styleOf(badge).position).not.toBe('absolute'); + expect(styleOf(badge).right).toBeUndefined(); + expect(styleOf(badge).top).toBeUndefined(); + // The badge is a direct sibling of the label in the button row, after it. + expect(badge.parent).toBe(button); + expect(hostText(hostRoot(childAt(badge, 0)))).toBe('3'); + }); + + it('renders no absolutely positioned node anywhere in the bar', () => { + const renderer = mountBar(3); + const absolutes = renderer.root.findAll(node => { + if (classesOf(node).includes('absolute')) { + return true; + } + return styleOf(node).position === 'absolute'; + }); + expect(absolutes).toHaveLength(0); + }); + + it('keeps the badge in-flow for a two-digit pending count', () => { + const renderer = mountBar(12); + const button = finishReviewButton(renderer); + expect(instanceChildren(button)).toHaveLength(2); + const badge = childAt(button, 1); + expect(String(badge.type)).toBe('View'); + expect(classesOf(badge)).not.toContain('absolute'); + expect(hostText(hostRoot(childAt(badge, 0)))).toBe('12'); + }); + + it('renders the label alone when the pending queue is empty', () => { + const renderer = mountBar(0); + const button = finishReviewButton(renderer); + expect(instanceChildren(button)).toHaveLength(1); + expect(hostText(hostRoot(childAt(button, 0)))).toBe('Finish review'); + const badges = renderer.root.findAll(node => classesOf(node).includes('rounded-full')); + expect(badges).toHaveLength(0); + }); +}); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.test.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.test.tsx index 4976c0477b..32bfb3a51e 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.test.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.test.tsx @@ -18,6 +18,7 @@ import '@/i18n'; import type * as ReactI18next from 'react-i18next'; import { PrDiffFloatingActions } from './pr-diff-floating-actions'; import { type PendingReviewItem } from '@/lib/pr-review/pending-review-provider'; +import { type ProviderPrRef } from '@/lib/pr-review/provider-pr-ref'; import { type SelectionState } from '@/lib/pr-review/diff-selection'; vi.mock('react-i18next', async importOriginal => { @@ -241,6 +242,66 @@ describe('PrDiffFloatingActions submit reachability (P1-F-46b)', () => { }); }); +// ── Provider arms (s6) ─────────────────────────────────────────────── +// +// The two sheets are route siblings on every provider, so a bar holding a +// provider ref pushes the sheet inside the ref's own route (the provider +// scope the layout publishes), never the GitHub sibling. + +const GITLAB_REF: ProviderPrRef = { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 12 }; +const BITBUCKET_REF: ProviderPrRef = { + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'api', + prId: 42, +}; + +describe('PrDiffFloatingActions provider routes (s6)', () => { + const selection: SelectionState = { + path: 'src/lib.ts', + side: 'RIGHT', + hunkKey: 'h1', + startLine: 3, + line: 5, + selectedText: 'x', + }; + + function pressButtonWith(prRef: ProviderPrRef | undefined, label: string): void { + routerPush.mockClear(); + // eslint-disable-next-line new-cap + const element = PrDiffFloatingActions({ ...baseProps, prRef, selection }); + const button = findElement({ + node: element, + type: 'Button', + prop: 'accessibilityLabel', + value: label, + }); + if (!button) { + throw new Error(`${label} button not found`); + } + (button.props as { onPress?: () => void }).onPress?.(); + } + + it('pushes the comment composer inside the GitLab ref route with the line params', () => { + pressButtonWith(GITLAB_REF, 'Comment on selected lines'); + + expect(routerPush).toHaveBeenCalledTimes(1); + expect(routerPush).toHaveBeenCalledWith( + '/(app)/pr-review/gitlab/group/sub/repo/12/comment-composer?path=src%2Flib.ts&side=RIGHT&line=5&startLine=3' + ); + }); + + it.each<[ProviderPrRef, string]>([ + [GITLAB_REF, '/(app)/pr-review/gitlab/group/sub/repo/12/review-submit'], + [BITBUCKET_REF, '/(app)/pr-review/bitbucket/acme/api/42/review-submit'], + ])('pushes the review-submit sheet inside the %s ref route', (prRef, expectedHref) => { + pressButtonWith(prRef, 'Finish review'); + + expect(routerPush).toHaveBeenCalledTimes(1); + expect(routerPush).toHaveBeenCalledWith(expectedHref); + }); +}); + describe('PrDiffFloatingActions bottom inset (plan §6)', () => { beforeEach(() => { insets.bottom = 0; @@ -249,18 +310,13 @@ describe('PrDiffFloatingActions bottom inset (plan §6)', () => { function findRootBar(): React.ReactElement | null { // eslint-disable-next-line new-cap const element = PrDiffFloatingActions(baseProps); - return findElement({ - node: element, - type: 'View', - prop: 'pointerEvents', - value: 'box-none', - }); + return element; } function rootPaddingBottom(): number | undefined { const root = findRootBar(); if (!root) { - throw new Error('floating action bar root not found'); + throw new Error('footer action bar root not found'); } return (root.props as { style?: { paddingBottom?: number } }).style?.paddingBottom; } @@ -274,28 +330,18 @@ describe('PrDiffFloatingActions bottom inset (plan §6)', () => { expect(rootPaddingBottom()).toBe(58); }); - it('reports the measured layout height through onHeightChange', () => { - const onHeightChange = vi.fn(() => undefined); - // eslint-disable-next-line new-cap - const element = PrDiffFloatingActions({ ...baseProps, onHeightChange }); - const root = findElement({ - node: element, - type: 'View', - prop: 'pointerEvents', - value: 'box-none', - }); + it('renders in-flow, not as an overlay over the list', () => { + // Spot check e3: the bar used to sit `absolute inset-x-0 bottom-0` over + // the FlashList, so a partly-scrolled diff row was clipped at its top + // edge. As an in-flow footer the list ends above it at every scroll + // position. + const root = findRootBar(); if (!root) { - throw new Error('floating action bar root not found'); + throw new Error('footer action bar root not found'); } - const onLayout = ( - root.props as { - onLayout?: (event: { nativeEvent: { layout: { height: number } } }) => void; - } - ).onLayout; - onLayout?.({ nativeEvent: { layout: { height: 150 } } }); - - expect(onHeightChange).toHaveBeenCalledTimes(1); - expect(onHeightChange).toHaveBeenCalledWith(150); + const classes = ((root.props as { className?: string }).className ?? '').split(' '); + expect(classes).not.toContain('absolute'); + expect(classes).toContain('w-full'); }); }); @@ -310,16 +356,10 @@ describe('PrDiffFloatingActions side insets (landscape)', () => { function rootBarStyle(): Record { // eslint-disable-next-line new-cap const element = PrDiffFloatingActions(baseProps); - const root = findElement({ - node: element, - type: 'View', - prop: 'pointerEvents', - value: 'box-none', - }); - if (!root) { - throw new Error('floating action bar root not found'); - } - return (root.props as { style?: Record }).style ?? {}; + // The bar is an in-flow footer below the list (not an overlay), so the + // component's returned element IS the bar root — there is no wrapper + // View with `pointerEvents: box-none` to find. + return (element.props as { style?: Record }).style ?? {}; } it('keeps exactly the current style keys at zero portrait insets', () => { diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx index a34fe59e6f..587e0ee44b 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx @@ -1,4 +1,7 @@ -// Floating action bar rendered over the PR diff FlashList. Hosts: +// Footer action bar rendered in-flow below the PR diff FlashList. The list +// ends at its top edge, so a diff row is never clipped by it at any scroll +// position (spot check e3: the bar floated over the list and cut the last +// src/beta.ts line). Hosts: // - The "Comment" affordance that pushes the comment-composer route // when a diff-line selection exists, plus a "Clear" button that // drops the selection. @@ -13,14 +16,16 @@ import { type Href, useRouter } from 'expo-router'; import { MessageCirclePlus } from '@/components/ui/icons'; import { useTranslation } from 'react-i18next'; -import { type LayoutChangeEvent, View } from 'react-native'; +import { View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import { providerPrSheetHref } from '@/components/pr-review/pr-review-provider-sheet-href'; import { clearDiffSelection } from '@/lib/pr-review/diff-selection-bridge'; import { type SelectionState } from '@/lib/pr-review/diff-selection'; import { type DiffViewMode } from '@/lib/pr-review/diff/pr-diff-list-items'; +import { type ProviderPrRef } from '@/lib/pr-review/provider-pr-ref'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { usePendingReview } from '@/lib/pr-review/pending-review-provider'; import { cn } from '@/lib/utils'; @@ -32,36 +37,41 @@ type PrDiffFloatingActionsProps = Readonly<{ owner: string; repo: string; number: number; + /** + * The provider ref when the diff renders under a GitLab / Bitbucket scope + * (s6). The two sheets are route siblings on every provider, so the bar + * pushes the sheet inside the ref's own route — pushing the GitHub sibling + * would leave the provider scope and write to the wrong provider. + */ + prRef?: ProviderPrRef; /** Unified (default) or side-by-side (tablet only). */ viewMode: DiffViewMode; /** `null` when no selection exists. Drives the "Comment" affordance. */ selection: SelectionState | null; /** Setter for the parent's selection state — `null` clears. */ onClearSelection: () => void; - /** Optional callback for the measured root layout height (points). */ - onHeightChange?: (height: number) => void; }>; export function PrDiffFloatingActions({ owner, repo, number, + prRef, viewMode, selection, onClearSelection, - onHeightChange, }: PrDiffFloatingActionsProps) { const router = useRouter(); const colors = useThemeColors(); const { t } = useTranslation(); const pending = usePendingReview(); - // The bar sits on the bottom edge, so its bottom padding must include the - // Android system inset. The measured height (onLayout) therefore already - // includes the inset, which `prDiffListBottomPadding` reserves for the list. - // Side insets clear the landscape sensor housing; like ScreenHeader they - // are spread only when nonzero, so the `px-4` gutter survives portrait - // (inline style wins over className), and they are horizontal-only, so the - // height-feeding bottom padding stays untouched. + // The footer is an in-flow bar below the list, so no row is ever clipped + // by it and nothing shows through around the opaque (`bg-background`) + // card. Its bottom padding must include the Android system inset. The + // landscape side insets (`insets.left` / `insets.right`) clear the sensor + // housing; like ScreenHeader they are spread only when nonzero, so the + // `px-4` gutter survives portrait (inline style wins over className), and + // they are horizontal-only, so the bottom padding stays untouched. const insets = useSafeAreaInsets(); const showSelectionAction = viewMode === 'unified' && selection !== null; @@ -74,22 +84,29 @@ export function PrDiffFloatingActions({ if (!selection) { return; } + const lineParams = { + path: selection.path, + side: selection.side, + line: selection.line, + ...(selection.startLine !== selection.line ? { startLine: selection.startLine } : {}), + }; + if (prRef) { + router.push(providerPrSheetHref(prRef, 'comment-composer', lineParams)); + return; + } const href: Href = { pathname: COMMENT_COMPOSER_PATH, - params: { - owner, - repo, - number, - path: selection.path, - side: selection.side, - line: selection.line, - ...(selection.startLine !== selection.line ? { startLine: selection.startLine } : {}), - }, + // The bracketed GitHub pathname needs the route segments as params. + params: { owner, repo, number, ...lineParams }, }; router.push(href); } function openReviewSubmit() { + if (prRef) { + router.push(providerPrSheetHref(prRef, 'review-submit')); + return; + } const href: Href = { pathname: REVIEW_SUBMIT_PATH, params: { owner, repo, number }, @@ -99,11 +116,7 @@ export function PrDiffFloatingActions({ return ( { - onHeightChange?.(event.nativeEvent.layout.height); - }} - pointerEvents="box-none" - className="absolute inset-x-0 bottom-0 items-center gap-2 px-4 pt-3" + className="w-full items-center gap-2 bg-background px-4 pt-3" style={{ paddingBottom: 24 + insets.bottom, ...(insets.left > 0 ? { paddingLeft: insets.left } : undefined), @@ -137,19 +150,23 @@ export function PrDiffFloatingActions({ ) : null} + {/* The Button row is `flex-row items-center justify-center gap-2`, so + the count badge is an in-flow pill AFTER the label. It used to ride + the label's top-right corner (`absolute -right-2.5 -top-2.5`), + which drew the opaque badge over the last glyphs of the label + (spot check e1-select-line / e1-line1-comment). In-flow the badge + can never cover the label, at any pending count or font scale. */} diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx index c592db7706..c11d423ca9 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx @@ -273,10 +273,13 @@ export function TabStateMessage({ title, message }: { title: string; message: st export function EmptyFilesView({ changedFiles, + noChangesDescription, onRequestOverview, refreshControl, }: { changedFiles: number; + /** Provider wording for the 0-changed-files case; GitHub copy when absent. */ + noChangesDescription?: string; onRequestOverview?: () => void; refreshControl?: ScrollViewProps['refreshControl']; }) { @@ -291,7 +294,7 @@ export function EmptyFilesView({ {changedFiles === 0 - ? t('prReview.noFilesChangedDescription') + ? (noChangesDescription ?? t('prReview.noFilesChangedDescription')) : t('prReview.hunkRows.filesStillLoading')} {onRequestOverview ? ( diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.mounted.test.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.mounted.test.tsx new file mode 100644 index 0000000000..203a827ce7 --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.mounted.test.tsx @@ -0,0 +1,86 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as diff-line.mounted.test.tsx) */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import '@/i18n'; +import { SideBySideRow } from './pr-diff-side-by-side-row'; +import { type ParsedDiffLine } from '@/lib/pr-review/diff/parse-patch'; +import { type SideBySideRow as SideBySideRowData } from '@/lib/pr-review/diff/side-by-side'; + +vi.mock('react-native', () => ({ + Text: 'RNText', + View: 'View', +})); +vi.mock('@/components/ui/text', async () => { + const React = await import('react'); + return { Text: 'Text', TextClassContext: React.createContext(undefined) }; +}); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ + background: '#FFFFFF', + foreground: '#111111', + mutedForeground: '#777777', + }), +})); + +function line(overrides: Partial = {}): ParsedDiffLine { + return { + type: 'context', + oldLine: 7, + newLine: 7, + text: 'const value = computeSomething(x);', + noNewlineAtEndOfFile: false, + ...overrides, + }; +} + +function row(overrides: Partial = {}): SideBySideRowData { + return { left: { line: line(overrides) }, right: { line: line(overrides) } }; +} + +function mountRow(data: SideBySideRowData): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | null } = { current: null }; + act(() => { + ref.current = TestRenderer.create( + createElement(SideBySideRow, { row: data, language: null, rowKeyId: 'row-7' }) + ); + }); + const created = ref.current; + if (created === null) { + throw new Error('the side-by-side row did not render'); + } + return created; +} + +describe('SideBySideRow gutter alignment', () => { + // Same defect class as the unified DiffLine gutter: a wrapped code line + // makes the column several visual lines tall, and a centered number would + // drift onto a later visual line instead of the column's start. + it('top-aligns both column gutters with the code first line', () => { + const renderer = mountRow( + row({ + text: 'const wrappedValue = someVeryLongExpression(thatDoesNotFitOnOneLine, atPhoneWidth);', + }) + ); + + const columns = renderer.root.findAll( + node => + node.type === ('View' as never) && + typeof node.props.className === 'string' && + node.props.className.includes('flex-1 flex-row items-stretch') + ); + expect(columns).toHaveLength(2); + + for (const column of columns) { + const children = column.props.children as TestRenderer.ReactTestInstance[]; + const gutter = children[0]; + if (gutter === undefined) { + throw new Error('the column rendered without a gutter'); + } + expect(gutter.props.className).toContain('justify-start'); + expect(gutter.props.className).not.toContain('justify-center'); + expect((gutter.props.style as { paddingTop: number }).paddingTop).toBe(2); + } + }); +}); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx index 8dde148dac..31ce207518 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx @@ -90,6 +90,9 @@ function SideColumnImpl({ line, side, language, isDark, foreground }: SideColumn const gutterStyle: ViewStyle = { width: COLUMN_GUTTER_WIDTH, minHeight: metrics.rowMinHeight, + // Top-aligned with the code's first line (see DiffLine's gutter): a + // centered number drifts onto a later visual line when the code wraps. + paddingTop: VERTICAL_PADDING, }; const codeContainerStyle: ViewStyle = { paddingVertical: VERTICAL_PADDING }; const codeBaseStyle: TextStyle = { @@ -114,7 +117,7 @@ function SideColumnImpl({ line, side, language, isDark, foreground }: SideColumn style={rowStyle} > {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic theme muted color */} diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-state-copy.ts b/apps/mobile/src/components/pr-review/diff/pr-diff-state-copy.ts new file mode 100644 index 0000000000..740d3f2625 --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-state-copy.ts @@ -0,0 +1,41 @@ +// Provider wording for the file list's terminal and empty states. +// +// GitLab calls this a merge request, and its copy must not name the Kilo +// GitHub App or "this pull request"; GitHub and Bitbucket both say pull +// request, so the provider term alone switches the strings and the list keeps +// one set of states for all three providers. + +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { type ProviderPrTriple, useProviderPrScope } from '@/lib/pr-review/provider-pr-ref'; + +type PrDiffStateCopy = { + readonly unavailableTitle: string; + readonly unavailableMessage: string; + readonly accessDeniedMessage: string; + /** Undefined keeps the GitHub wording inside `EmptyFilesView`. */ + readonly noChangesDescription: string | undefined; +}; + +export function usePrDiffStateCopy(triple: ProviderPrTriple): PrDiffStateCopy { + const { t } = useTranslation(); + const isMergeRequest = useProviderPrScope(triple).ref.platform === 'gitlab'; + return useMemo( + () => ({ + unavailableTitle: isMergeRequest + ? t('prReview.terms.mergeRequestUnavailable') + : t('prReview.pullRequestUnavailable'), + unavailableMessage: isMergeRequest + ? t('prReview.terms.unavailableDescription') + : t('prReview.pullRequestUnavailableDescription'), + accessDeniedMessage: isMergeRequest + ? t('prReview.terms.accessDeniedMergeRequest') + : t('prReview.accessDeniedDescription'), + noChangesDescription: isMergeRequest + ? t('prReview.terms.noFilesChangedDescription') + : undefined, + }), + [isMergeRequest, t] + ); +} diff --git a/apps/mobile/src/components/pr-review/discussion/comment-row.test.tsx b/apps/mobile/src/components/pr-review/discussion/comment-row.test.tsx index c6e6481844..5f53a7fa87 100644 --- a/apps/mobile/src/components/pr-review/discussion/comment-row.test.tsx +++ b/apps/mobile/src/components/pr-review/discussion/comment-row.test.tsx @@ -302,3 +302,43 @@ describe('CommentRow overflow actions', () => { renderer.unmount(); }); }); + +describe('CommentRow reactions capability gate (s6)', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + async function renderWithCapabilities( + reactionsSupported: boolean + ): Promise { + let renderer: TestRenderer.ReactTestRenderer | null = null; + await act(async () => { + await Promise.resolve(); + renderer = TestRenderer.create( + createElement(CommentRow, { + comment: makeComment(), + onToggleReaction: vi.fn<() => void>(), + readOnly: true, + reactionsSupported, + }) + ); + }); + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition + if (!renderer) { + throw new Error('Failed to create test renderer'); + } + return renderer; + } + + it('supported (default): renders the reactions row', async () => { + const renderer = await renderWithCapabilities(true); + expect(renderer.root.findAll(node => (node.type as string) === 'ReactionsRow')).toHaveLength(1); + renderer.unmount(); + }); + + it('unsupported: renders no reactions row at all — never an empty or failing one', async () => { + const renderer = await renderWithCapabilities(false); + expect(renderer.root.findAll(node => (node.type as string) === 'ReactionsRow')).toHaveLength(0); + renderer.unmount(); + }); +}); diff --git a/apps/mobile/src/components/pr-review/discussion/comment-row.tsx b/apps/mobile/src/components/pr-review/discussion/comment-row.tsx index 18fa553fc4..fa034f1439 100644 --- a/apps/mobile/src/components/pr-review/discussion/comment-row.tsx +++ b/apps/mobile/src/components/pr-review/discussion/comment-row.tsx @@ -43,6 +43,13 @@ type CommentRowProps = { readonly onToggleReaction: (content: ReviewReactionContent) => void; readonly reactionsDisabled?: boolean; readonly readOnly?: boolean; + /** + * The `capabilities.reactions.supported` flag (s6). False renders NO + * reactions row at all — the provider has no reaction affordance to + * offer, so the row shows nothing instead of an empty or failing one. + * Defaults to true, so the GitHub call sites are unchanged. + */ + readonly reactionsSupported?: boolean; /** The viewer's GitHub login, used to disable self-target moderation. */ readonly viewerLogin?: string | null; }; @@ -96,6 +103,7 @@ export function CommentRow({ onToggleReaction, reactionsDisabled, readOnly, + reactionsSupported = true, viewerLogin = null, }: Readonly) { const authorName = selectCommentAuthorName(comment.author); @@ -257,12 +265,14 @@ export function CommentRow({ - + {reactionsSupported ? ( + + ) : null} ); } diff --git a/apps/mobile/src/components/pr-review/discussion/discussion-thread.provider-gate.test.tsx b/apps/mobile/src/components/pr-review/discussion/discussion-thread.provider-gate.test.tsx new file mode 100644 index 0000000000..a408a3490f --- /dev/null +++ b/apps/mobile/src/components/pr-review/discussion/discussion-thread.provider-gate.test.tsx @@ -0,0 +1,243 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to test React/RN structure under vitest */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import { type ReviewThread } from '@/lib/pr-review/discussion/review-discussion-types'; +import { type ProviderPrRef, ProviderPrScopeProvider } from '@/lib/pr-review/provider-pr-ref'; + +import { DiscussionThread } from './discussion-thread'; + +// The s6 write arm of the provider discussion surface. The reply and resolve +// mutations route through the `providerReview` seam on a GitLab MR / Bitbucket +// PR (the hooks pick the arm from the provider scope the layout publishes), so +// the thread OFFERS resolve and reply on every platform whose capabilities +// allow it — with the provider-native ids (discussion id / verbatim comment +// id) riding on the wire. Reactions have no seam write path and no provider +// read layer returns reaction data, so a provider comment row stays read-only: +// the row shows nothing rather than a dead or failing affordance, and the +// capability flag removes the reaction row entirely where the provider says +// unsupported (Bitbucket). The read-only facts the providers DO report — the +// Resolved badge — stay rendered. + +const GITLAB_REF: ProviderPrRef = { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 12 }; +const BITBUCKET_REF: ProviderPrRef = { + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'api', + prId: 42, +}; + +function makeThread(overrides: Partial = {}): ReviewThread { + return { + threadId: 'D-12', + isResolved: false, + isOutdated: false, + subjectType: 'LINE', + path: 'src/index.ts', + line: 10, + startLine: null, + originalLine: null, + originalStartLine: null, + diffSide: 'RIGHT', + diffHunk: null, + comments: [ + { + commentId: 12, + nodeId: '12', + author: { login: 'alice', avatarUrl: null }, + bodyMarkdown: 'hello', + createdAt: '2024-01-01T00:00:00Z', + reactions: [], + }, + ], + ...overrides, + }; +} + +const baseProps = { + owner: 'group/sub', + repo: 'repo', + number: 12, + onToggleExpand: vi.fn<() => void>(), +}; + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + View: 'View', +})); +vi.mock('expo-haptics', () => ({ selectionAsync: vi.fn() })); +vi.mock('@/components/ui/icons', () => ({ + Check: 'Check', + CheckCheck: 'CheckCheck', + ChevronDown: 'ChevronDown', + ChevronUp: 'ChevronUp', +})); +vi.mock('@/components/pr-review/discussion/comment-row', () => ({ CommentRow: 'CommentRow' })); +vi.mock('@/components/pr-review/discussion/reply-input', () => ({ ReplyInput: 'ReplyInput' })); +vi.mock('@/components/pr-review/discussion/thread-diff-snippet', () => ({ + ThreadDiffSnippet: 'ThreadDiffSnippet', +})); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#6F6A61', good: '#22C55E' }), +})); +const { resolveMutate } = vi.hoisted(() => ({ resolveMutate: vi.fn<() => void>() })); + +vi.mock('@/lib/pr-review/discussion/use-review-discussion-mutations', () => ({ + useAddReactionMutation: () => ({ mutate: vi.fn(), isPending: false }), + useRemoveReactionMutation: () => ({ mutate: vi.fn(), isPending: false }), + useReplyToCommentMutation: () => ({ mutate: vi.fn(), isPending: false }), + useResolveThreadMutation: () => ({ mutate: resolveMutate, isPending: false }), + useUnresolveThreadMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +function countByType( + root: TestRenderer.ReactTestInstance, + type: string, + match?: (props: Record) => boolean +): number { + return root.findAll( + node => + typeof node.type === 'string' && + (node.type as string) === type && + (match === undefined || match(node.props as Record)) + ).length; +} + +async function renderThread( + ref: ProviderPrRef | null, + expanded: boolean, + thread: ReviewThread +): Promise { + const card = createElement(DiscussionThread, { ...baseProps, thread, expanded }); + const tree = ref ? ( + {card} + ) : ( + card + ); + let renderer: TestRenderer.ReactTestRenderer | null = null; + await act(async () => { + await Promise.resolve(); + renderer = TestRenderer.create(tree); + }); + // Runtime safety: act() could theoretically fail without assigning. + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition + if (!renderer) { + throw new Error('Failed to create test renderer'); + } + return renderer; +} + +function pressableCount(root: TestRenderer.ReactTestInstance, label: string): number { + return countByType( + root, + 'Pressable', + props => props.accessibilityLabel === label || props.children === label + ); +} + +/** Press the resolve toggle (its own nested pressable in the header). */ +function pressResolve(root: TestRenderer.ReactTestInstance): void { + const resolveToggle = root.findAll( + node => + typeof node.type === 'string' && + (node.type as string) === 'Pressable' && + (node.props as Record).accessibilityLabel === 'Resolve thread' + )[0]; + if (!resolveToggle) { + throw new Error('Resolve toggle not found'); + } + (resolveToggle.props as { onPress?: () => void }).onPress?.(); +} + +describe('DiscussionThread provider write arm (s6)', () => { + it.each<[string, ProviderPrRef]>([ + ['gitlab', GITLAB_REF], + ['bitbucket', BITBUCKET_REF], + ])('offers resolve and reply through the seam on a %s thread', async (_platform, ref) => { + const renderer = await renderThread(ref, true, makeThread()); + try { + // Resolve is offered and carries the provider-native thread id. + expect(pressableCount(renderer.root, 'Resolve thread')).toBe(1); + pressResolve(renderer.root); + expect(resolveMutate).toHaveBeenCalledWith({ threadId: 'D-12' }); + + // Reply is offered with the provider target (discussion id + verbatim + // comment id), so the seam posts it for this provider identity. + expect(countByType(renderer.root, 'ReplyInput')).toBe(1); + const replyInput = renderer.root.find( + node => typeof node.type === 'string' && (node.type as string) === 'ReplyInput' + ); + expect(replyInput.props.provider).toEqual({ + ref, + threadId: 'D-12', + commentNodeId: '12', + }); + + expect(countByType(renderer.root, 'CommentRow')).toBe(1); + } finally { + renderer.unmount(); + } + }); + + it('makes thread comments read-only on a provider scope (no reaction write path)', async () => { + const renderer = await renderThread(GITLAB_REF, true, makeThread()); + try { + const row = renderer.root.find( + node => typeof node.type === 'string' && (node.type as string) === 'CommentRow' + ); + expect(row.props.readOnly).toBe(true); + // GitLab's capability says reactions are supported, so the row is + // gated on the flag (true) — but it is read-only and the read layer + // returns no reaction data, so nothing renders. + expect(row.props.reactionsSupported).toBe(true); + } finally { + renderer.unmount(); + } + }); + + it('drops the reaction row entirely on a Bitbucket thread (capability unsupported)', async () => { + const renderer = await renderThread(BITBUCKET_REF, true, makeThread()); + try { + const row = renderer.root.find( + node => typeof node.type === 'string' && (node.type as string) === 'CommentRow' + ); + expect(row.props.reactionsSupported).toBe(false); + expect(row.props.readOnly).toBe(true); + } finally { + renderer.unmount(); + } + }); + + it('keeps the resolve toggle, reply input and writable reactions on GitHub', async () => { + const renderer = await renderThread(null, true, makeThread()); + try { + expect(pressableCount(renderer.root, 'Resolve thread')).toBe(1); + expect(countByType(renderer.root, 'ReplyInput')).toBe(1); + const replyInput = renderer.root.find( + node => typeof node.type === 'string' && (node.type as string) === 'ReplyInput' + ); + // GitHub carries no provider target — the exact pre-s6 call shape. + expect(replyInput.props.provider).toBeUndefined(); + const row = renderer.root.find( + node => typeof node.type === 'string' && (node.type as string) === 'CommentRow' + ); + expect(row.props.readOnly).toBe(false); + } finally { + renderer.unmount(); + } + }); + + it('keeps the read-only Resolved badge on a provider thread', async () => { + const renderer = await renderThread(GITLAB_REF, false, makeThread({ isResolved: true })); + try { + // Collapsed provider thread: the unresolve affordance is offered + // through the seam, and the read-only Resolved badge stays. + expect(pressableCount(renderer.root, 'Unresolve thread')).toBe(1); + expect(countByType(renderer.root, 'Text', props => props.children === 'Resolved')).toBe(1); + } finally { + renderer.unmount(); + } + }); +}); diff --git a/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx index 4a7c3f14fe..661b6f3d58 100644 --- a/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx +++ b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx @@ -55,6 +55,7 @@ import { useResolveThreadMutation, useUnresolveThreadMutation, } from '@/lib/pr-review/discussion/use-review-discussion-mutations'; +import { providerPrCapabilities, useProviderPrScope } from '@/lib/pr-review/provider-pr-ref'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn, parseTimestamp, timeAgo } from '@/lib/utils'; @@ -82,6 +83,19 @@ export function DiscussionThread({ viewerLogin = null, onReplyFocus, }: Readonly) { + // s6: the reply and resolve writes route through the `providerReview` seam + // on a GitLab MR / Bitbucket PR (the mutation hooks pick the arm from the + // provider scope the layout publishes), so what this card offers is decided + // by the capability list, not by the platform. Reactions stay behind the + // GitHub write path: the seam has no reaction procedure and no provider + // read layer returns reaction data, so a provider comment row renders + // read-only — the row shows nothing rather than a dead or failing + // affordance. + const scope = useProviderPrScope({ owner, repo, number }); + const capabilities = providerPrCapabilities(scope.ref.platform); + const isGithub = scope.ref.platform === 'github'; + const canReply = capabilities.canComment; + const canResolve = capabilities.canResolveThreads; const resolve = useResolveThreadMutation(); const unresolve = useUnresolveThreadMutation(); const addReaction = useAddReactionMutation(thread.threadId); @@ -134,11 +148,23 @@ export function DiscussionThread({ firstTimestamp: firstComment?.createdAt ?? null, expanded, onToggleResolve, + canResolve, resolveDisabled: isResolving, onToggleExpand, } as const; if (expanded) { + // The provider reply target carries the provider-native ids the seam + // needs: the thread's discussion/root-comment id and the first comment's + // verbatim provider id (kept in `nodeId` by the read layer). + const providerReply = + !isGithub && firstComment + ? { + ref: scope.ref, + threadId: thread.threadId, + commentNodeId: firstComment.nodeId, + } + : undefined; return ( { onToggleReaction(comment, content); @@ -160,13 +188,14 @@ export function DiscussionThread({ ))} - {firstComment ? ( + {firstComment && canReply ? ( ) : null} @@ -199,6 +228,8 @@ type ThreadHeaderProps = { readonly expanded: boolean; readonly onToggleExpand: () => void; readonly onToggleResolve: () => void; + /** False on a GitLab/Bitbucket scope: the resolve control is withheld. */ + readonly canResolve: boolean; readonly resolveDisabled: boolean; }; @@ -212,6 +243,7 @@ function ThreadHeader({ expanded, onToggleExpand, onToggleResolve, + canResolve, resolveDisabled, }: Readonly) { const colors = useThemeColors(); @@ -241,7 +273,9 @@ function ThreadHeader({ {anchorLabel} - + {canResolve ? ( + + ) : null} {resolved ? ( diff --git a/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.mounted.test.tsx b/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.mounted.test.tsx new file mode 100644 index 0000000000..b0df460d0f --- /dev/null +++ b/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.mounted.test.tsx @@ -0,0 +1,165 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the repository's native-free mounted test tool. */ +import { createElement, Fragment, type ReactNode } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type DiscussionListItem } from '@/lib/pr-review/discussion/review-discussion-types'; +import { ProviderPrScopeProvider } from '@/lib/pr-review/provider-pr-ref'; +import { PrReviewDiscussionList } from './pr-review-discussion-list'; + +type TaggedOptions = { tag: string; input?: unknown }; + +const observed = vi.hoisted(() => ({ options: [] as TaggedOptions[] })); + +// Records every query the list mounts and answers the overview with a viewer +// login, so a test can assert BOTH which namespace was asked and what the +// answer drives. +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})); +vi.mock('@tanstack/react-query', () => ({ + useQuery: (options: TaggedOptions) => { + observed.options.push(options); + if (options.tag === 'moderation') { + return { data: { blockedLogins: [], mutedLogins: [] } }; + } + return { data: { repo: { viewerLogin: 'octocat' } } }; + }, +})); +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + moderation: { listHiddenUsers: { queryOptions: () => ({ tag: 'moderation' }) } }, + githubPrReview: { + getPullRequest: { + queryOptions: (input: unknown) => ({ tag: 'githubPrReview.getPullRequest', input }), + }, + }, + providerReview: { + getPullRequest: { + queryOptions: (input: unknown) => ({ tag: 'providerReview.getPullRequest', input }), + }, + }, + }), +})); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('react-native', () => ({ View: 'View' })); +vi.mock('@shopify/flash-list', () => ({ + FlashList: ({ + data, + renderItem, + }: { + data: readonly unknown[]; + renderItem: (args: { item: unknown; index: number }) => ReactNode; + }) => + createElement( + Fragment, + null, + data.map((item, index) => + createElement(Fragment, { key: index }, renderItem({ item, index })) + ) + ), +})); +vi.mock('@/components/pr-review/discussion/comment-row', () => ({ CommentRow: 'CommentRow' })); +vi.mock('@/components/pr-review/discussion/discussion-thread', () => ({ + DiscussionThread: 'DiscussionThread', +})); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/lib/screen-insets', () => ({ useDetailScreenBottomPadding: () => 0 })); + +function noop(): void { + // The viewer query is what this file asserts; the list callbacks are inert. +} + +const listItems: readonly DiscussionListItem[] = [ + { + kind: 'comment', + comment: { + commentId: 1, + nodeId: 'c1', + author: { login: 'octocat', avatarUrl: null }, + bodyMarkdown: 'hello', + createdAt: '2026-01-01T00:00:00Z', + reactions: [], + }, + }, +]; + +function mountList(scope?: { + ref: { platform: 'gitlab'; projectPath: string; mrIid: number }; + organizationId: string | null; +}) { + const list = ( + + ); + const created: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + created.current = TestRenderer.create( + scope ? {list} : list + ); + }); + const renderer = created.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +describe('PrReviewDiscussionList viewer query', () => { + beforeEach(() => { + observed.options = []; + }); + + it('reads the viewer login from the GitHub procedure on a GitHub scope', () => { + const renderer = mountList(); + + expect(observed.options.map(options => options.tag)).toEqual([ + 'moderation', + 'githubPrReview.getPullRequest', + ]); + expect(observed.options[1]?.input).toEqual({ owner: 'group/sub', repo: 'repo', number: 12 }); + expect( + renderer.root.findAll(node => String(node.type) === 'CommentRow')[0]?.props.viewerLogin + ).toBe('octocat'); + + act(() => { + renderer.unmount(); + }); + }); + + it('never fires the GitHub procedure under a GitLab scope', () => { + const renderer = mountList({ + ref: { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 12 }, + organizationId: null, + }); + + expect(observed.options.map(options => options.tag)).toEqual([ + 'moderation', + 'providerReview.getPullRequest', + ]); + expect(observed.options[1]?.input).toMatchObject({ + platform: 'gitlab', + projectPath: 'group/sub/repo', + mrIid: 12, + }); + + act(() => { + renderer.unmount(); + }); + }); +}); diff --git a/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.tsx b/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.tsx index 1d780538e5..9787ea886a 100644 --- a/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.tsx +++ b/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.tsx @@ -18,6 +18,7 @@ import { type ReviewThread, } from '@/lib/pr-review/discussion/review-discussion-types'; import { expandedForThread } from '@/lib/pr-review/discussion/thread-expansion'; +import { useProviderPrQueries } from '@/lib/pr-review/provider-pr-queries'; import { useDetailScreenBottomPadding } from '@/lib/screen-insets'; import { useTRPC } from '@/lib/trpc'; @@ -76,8 +77,14 @@ export function PrReviewDiscussionList({ const trpc = useTRPC(); // Account-local hidden users (blocked + muted GitHub logins) filter rows. const hiddenUsers = useQuery(trpc.moderation.listHiddenUsers.queryOptions()); - // Viewer login for self-target gating on the comment overflow menu. - const pr = useQuery(trpc.githubPrReview.getPullRequest.queryOptions({ owner, repo, number })); + // Viewer login for self-target gating on the comment overflow menu. The + // overview goes through the provider seam, not `githubPrReview` directly: + // this list also renders under a GitLab MR / Bitbucket PR scope, where the + // GitHub-shaped triple is a synthesized stand-in and a GitHub call with it + // would fail on every render. On GitHub the key is unchanged, so this + // still dedupes with the screen's own overview query. + const queries = useProviderPrQueries({ owner, repo, number }); + const pr = useQuery(queries.overviewOptions()); const viewerLogin = pr.data?.repo.viewerLogin ?? null; const hiddenLogins = useMemo(() => { @@ -143,6 +150,9 @@ export function PrReviewDiscussionList({ diff --git a/apps/mobile/src/components/pr-review/discussion/reply-input.test.ts b/apps/mobile/src/components/pr-review/discussion/reply-input.test.ts index 86f2540f3c..bc9500b0ad 100644 --- a/apps/mobile/src/components/pr-review/discussion/reply-input.test.ts +++ b/apps/mobile/src/components/pr-review/discussion/reply-input.test.ts @@ -19,6 +19,7 @@ import { ensureTermsAcceptedOutcome, ReplyInput } from './reply-input'; import { clearDraft } from '@/lib/persist/drafts'; import { PR_OPERATION_AMBIGUOUS_MESSAGE } from '@/lib/pr-review/merge/pr-operation-ledger'; import { type useReplyToCommentMutation } from '@/lib/pr-review/discussion/use-review-discussion-mutations'; +import { type ProviderPrRef, providerPrRefKey } from '@/lib/pr-review/provider-pr-ref'; vi.mock('react-i18next', async importOriginal => { const actual = await importOriginal(); @@ -121,9 +122,13 @@ vi.mock('@/lib/hooks/use-current-user-id', () => ({ // `ReplyInput` is mounted by calling it as a plain function (no renderer), so // the React hook primitives are stubbed to no-op/simple versions, mirroring // pr-merge-sheet.test.tsx. The pure `ensureTermsAcceptedOutcome` tests above -// do not touch these. useState keeps a box per slot (same pattern as the -// composer test) so a press can flip the inline-error state and the next -// mount renders it. +// do not touch these. +// +// useState keeps a box per slot (same pattern as the composer test) so a +// press can flip the inline-error state and the next mount renders it, and it +// records every setter it hands out so the s6f refused-reply tests can observe +// the copy the error effect writes even without a re-render. +const stateSetters = vi.hoisted(() => [] as { mock: { calls: unknown[][] } }[]); const hookState = vi.hoisted(() => ({ boxes: [] as unknown[], cursor: 0 })); vi.mock('react', async () => { @@ -136,13 +141,14 @@ vi.mock('react', async () => { if (hookState.boxes.length <= index) { hookState.boxes.push(initial); } - const write = (value: T) => { + const write = vi.fn((value: T) => { hookState.boxes[index] = typeof value === 'function' ? (value as (prev: T) => T)(hookState.boxes[index] as T) : value; - }; - return [hookState.boxes[index] as T, write] as [T, (value: T) => void]; + }); + stateSetters.push(write as unknown as { mock: { calls: unknown[][] } }); + return [hookState.boxes[index] as T, write as (value: T) => void] as [T, (value: T) => void]; }), useMemo: vi.fn((factory: () => T) => factory()), useRef: vi.fn((initial: T) => { @@ -501,6 +507,82 @@ describe('ReplyInput seeds the field from the settled draft during render', () = }); }); +// ── Provider arm (s6) ──────────────────────────────────────────────── + +const GITLAB_REF: ProviderPrRef = { platform: 'gitlab', projectPath: 'octocat/hello', mrIid: 1 }; + +describe('ReplyInput provider arm (s6)', () => { + beforeEach(() => { + alertCalls.length = 0; + getTermsStatusMock.mockReset(); + acceptTermsMock.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + function mountProviderReply(mutate: unknown): void { + // eslint-disable-next-line new-cap + const element = ReplyInput({ + owner: 'octocat', + repo: 'hello', + number: 1, + commentId: 42, + reply: makeReply(mutate), + provider: { ref: GITLAB_REF, threadId: 'D-77', commentNodeId: '9001' }, + }); + const input = findElement({ + node: element, + type: 'TextInput', + prop: 'accessibilityLabel', + value: 'Reply body', + }); + if (!input) { + throw new Error('Reply body TextInput not found'); + } + (input.props as { onChangeText?: (value: string) => void }).onChangeText?.('hello'); + const button = findElement({ + node: element, + type: 'Button', + prop: 'accessibilityLabel', + value: 'Submit reply', + }); + if (!button) { + throw new Error('Submit reply Button not found'); + } + (button.props as { onPress?: () => void }).onPress?.(); + } + + it('posts the seam vars (provider-native ids), never the GitHub-shaped input', async () => { + getTermsStatusMock.mockResolvedValue({ accepted: true, currentVersion: 'v1' }); + const mutate = vi.fn((_input: unknown, options: { onSuccess?: () => void }) => { + options.onSuccess?.(); + }); + mountProviderReply(mutate); + await flush(); + + expect(mutate).toHaveBeenCalledWith( + { threadId: 'D-77', commentNodeId: '9001', body: 'hello' }, + expect.anything() + ); + }); + + it('folds the provider ref identity into the durable reply draft key', async () => { + getTermsStatusMock.mockResolvedValue({ accepted: true, currentVersion: 'v1' }); + const mutate = vi.fn((_input: unknown, options: { onSuccess?: () => void }) => { + options.onSuccess?.(); + }); + mountProviderReply(mutate); + await flush(); + + // The mocked prReplyDraftKey answers 'pr-reply:key'; the provider arm + // appends the collision-free ref identity (identity rule 17) so a + // same-numbered GitHub PR can never share this reply's draft. + expect(clearDraft).toHaveBeenCalledWith('u1', `pr-reply:key@${providerPrRefKey(GITLAB_REF)}`); + }); +}); + describe('ReplyInput gates input on draft settle', () => { beforeEach(() => { hookState.boxes = []; @@ -542,6 +624,66 @@ describe('ReplyInput gates input on draft settle', () => { }); }); +// ── s6f: refused-reply wording ─────────────────────────────────────── + +/** True when the error effect wrote `value` into any state slot. */ +function stateValueWritten(value: string): boolean { + return stateSetters.some(setter => setter.mock.calls.some(call => call[0] === value)); +} + +function forbiddenError(): Error { + return Object.assign(new Error('403 Forbidden'), { data: { code: 'FORBIDDEN' } }); +} + +describe('ReplyInput refused-reply wording (s6f)', () => { + beforeEach(() => { + stateSetters.length = 0; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + function mountWithForbiddenError(provider?: { + ref: ProviderPrRef; + threadId: string; + commentNodeId: string; + }): void { + const errored = { + mutate: vi.fn(), + isPending: false, + error: forbiddenError(), + } as unknown as ReplyMutation; + // eslint-disable-next-line new-cap + ReplyInput({ + owner: 'octocat', + repo: 'hello', + number: 1, + commentId: 42, + reply: errored, + provider, + }); + } + + it('words a refused provider reply after the merge-request noun', () => { + mountWithForbiddenError({ ref: GITLAB_REF, threadId: 'D-77', commentNodeId: '9001' }); + // The provider 403 must never read "pull request" on a merge request. + expect(stateValueWritten("You don't have permission to reply to this merge request.")).toBe( + true + ); + expect(stateValueWritten("You don't have permission to reply to this pull request.")).toBe( + false + ); + }); + + it('keeps the exact pre-s6 forbidden copy on the GitHub arm', () => { + mountWithForbiddenError(); + expect(stateValueWritten("You don't have permission to reply to this pull request.")).toBe( + true + ); + }); +}); + // The failed-reply surface (uxs3 spot check, e6-offline-hang / e6-offline- // banner): a generic provider failure shows the specified retryable copy — // never the raw GitHub text — and a CONFIRMED-offline submit fails at once diff --git a/apps/mobile/src/components/pr-review/discussion/reply-input.tsx b/apps/mobile/src/components/pr-review/discussion/reply-input.tsx index 832e8c7501..60bafd05ae 100644 --- a/apps/mobile/src/components/pr-review/discussion/reply-input.tsx +++ b/apps/mobile/src/components/pr-review/discussion/reply-input.tsx @@ -10,6 +10,7 @@ import * as WebBrowser from 'expo-web-browser'; import { UGC_AGE_POSTURE } from '@kilocode/app-shared/moderation'; import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice'; +import { providerPrNounKey } from '@/components/pr-review/pr-review-provider-noun'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { i18n } from '@/i18n'; @@ -26,6 +27,7 @@ import { isPrOperationAmbiguous, isPrOperationPersistenceFailed, } from '@/lib/pr-review/merge/pr-operation-ledger'; +import { type ProviderPrRef, providerPrRefKey } from '@/lib/pr-review/provider-pr-ref'; import { trpcClient } from '@/lib/trpc'; /** @@ -143,6 +145,20 @@ type ReplyInputProps = { readonly number: number; readonly commentId: number; readonly reply: ReturnType; + /** + * The provider arm (s6). Present on a GitLab MR / Bitbucket PR thread: + * the reply posts `{ threadId, commentNodeId, body }` through the + * `providerReview` seam (GitLab answers inside the discussion, Bitbucket + * attaches to the root comment), and the durable draft key folds the + * collision-free ref identity so the same-numbered PR on another provider + * can never share this reply's draft (identity rule 17). Absent on GitHub, + * which keeps the exact pre-s6 call and key bytes. + */ + readonly provider?: { + readonly ref: ProviderPrRef; + readonly threadId: string; + readonly commentNodeId: string; + }; /** * Invoked when the reply field gains focus. The discussion tab uses it to * scroll the focused thread row above the keyboard-lifted bottom CTA bar @@ -157,6 +173,7 @@ export function ReplyInput({ number, commentId, reply, + provider, onInputFocus, }: Readonly) { const colors = useThemeColors(); @@ -168,11 +185,19 @@ export function ReplyInput({ 'retryable' | 'bad-request' | 'forbidden' | 'reconnect' | null >(null); const [resetKey, setResetKey] = useState(0); + // The provider platform as a stable primitive: the error effect words a + // refusal after it without depending on the `provider` object identity. + const providerPlatform = provider?.ref.platform; // Durable reply draft, keyed by account and thread. Nothing is saved or // restored while the user id is unknown. const { userId, isLoading: isIdentityLoading } = useCurrentUserId(); - const replyDraftKey = prReplyDraftKey(owner, repo, number, commentId); + const positionReplyDraftKey = prReplyDraftKey(owner, repo, number, commentId); + // Provider arms fold the collision-free ref identity into the key (identity + // rule 17); the GitHub bytes stay exactly as stored before this slice. + const replyDraftKey = provider + ? `${positionReplyDraftKey}@${providerPrRefKey(provider.ref)}` + : positionReplyDraftKey; const draft = useFencedDraftLoad({ userId, isIdentityLoading, entityKey: replyDraftKey }); useDraftFlushOnBackground(userId, replyDraftKey, true); @@ -229,7 +254,15 @@ export function ReplyInput({ setInlineError(t('prReview.discussion.replyBadRequest')); setInlineErrorKind('bad-request'); } else if (classification.kind === 'forbidden') { - setInlineError(t('prReview.discussion.replyForbidden')); + // The provider arm words the refusal after the connected provider + // (merge request vs pull request); GitHub keeps the exact pre-s6 copy. + setInlineError( + providerPlatform + ? t('prReview.discussion.replyForbiddenTerm', { + term: t(providerPrNounKey(providerPlatform)), + }) + : t('prReview.discussion.replyForbidden') + ); setInlineErrorKind('forbidden'); } else if (classification.kind === 'reconnect') { setInlineError(t('prReview.connectionExpired')); @@ -243,7 +276,7 @@ export function ReplyInput({ setInlineErrorKind('retryable'); } } - }, [reply.error, t]); + }, [reply.error, t, providerPlatform]); const submit = async () => { const body = bodyRef.current.trim(); @@ -271,7 +304,12 @@ export function ReplyInput({ return; } reply.mutate( - { owner, repo, number, commentId, body }, + // The provider arm posts the seam vars; the mutation hook routes the + // call by the live provider scope, so the ids here are provider-native + // (discussion id / root-comment id), never GitHub's numeric comment id. + provider + ? { threadId: provider.threadId, commentNodeId: provider.commentNodeId, body } + : { owner, repo, number, commentId, body }, { onSuccess: () => { bodyRef.current = ''; diff --git a/apps/mobile/src/components/pr-review/full-surface-states.mounted.test.tsx b/apps/mobile/src/components/pr-review/full-surface-states.mounted.test.tsx index c783ff16c5..49ca92f9fe 100644 --- a/apps/mobile/src/components/pr-review/full-surface-states.mounted.test.tsx +++ b/apps/mobile/src/components/pr-review/full-surface-states.mounted.test.tsx @@ -4,6 +4,7 @@ import { RefreshControl } from '@/components/ui/refresh-control'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import '@/i18n'; +import { type ProviderPrScope, ProviderPrScopeProvider } from '@/lib/pr-review/provider-pr-ref'; import { PrReviewOverview } from './pr-review-overview'; import { PrReviewCommentComposerScreen } from './pr-review-comment-composer-screen'; import { PrReviewReviewSubmitScreen } from './pr-review-review-submit-screen'; @@ -24,16 +25,22 @@ vi.mock('@tanstack/react-query', async importOriginal => ({ ...(await importOriginal()), useQuery: () => query, })); -vi.mock('expo-router', () => ({ - useRouter: () => ({ back: vi.fn(), push: vi.fn() }), - useLocalSearchParams: () => ({ +// Mutable so one suite can hand the composer route a malformed param set +// without re-mocking `expo-router` per test. +const routeParams = vi.hoisted(() => { + const current: Record = { owner: 'org', repo: 'repo', number: '1', path: 'src/a.ts', line: '1', side: 'RIGHT', - }), + }; + return { current }; +}); +vi.mock('expo-router', () => ({ + useRouter: () => ({ back: vi.fn(), push: vi.fn(), replace: vi.fn() }), + useLocalSearchParams: () => routeParams.current, })); vi.mock('react-native', () => ({ View: 'View', @@ -63,6 +70,9 @@ vi.mock('@/components/pr-review/diff/pr-diff-file-navigator', () => ({ vi.mock('@/components/pr-review/merge/pr-merge-section', () => ({ PrMergeSection: 'PrMergeSection', })); +vi.mock('@/components/pr-review/merge/pr-merge-section-provider', () => ({ + PrMergeSectionProvider: 'PrMergeSectionProvider', +})); vi.mock('@/components/pr-review/pr-review-checks-section', () => ({ PrReviewChecksSection: 'PrReviewChecksSection', })); @@ -102,6 +112,14 @@ vi.mock('@/lib/trpc', () => ({ useTRPC: () => ({ githubPrReview: { getPullRequest: { queryOptions: () => ({}) } }, githubApps: { getUserAuthorization: { queryKey: () => [] } }, + // s6: the submit/merge screens build the provider seam's capability and + // merge-state options even on the GitHub arm (the queries register + // disabled and never fetch), so the router mock carries the namespace. + providerReview: { + getCapabilities: { queryOptions: () => ({}) }, + getMergeState: { queryOptions: () => ({}) }, + getPullRequest: { queryOptions: () => ({}) }, + }, }), })); @@ -110,6 +128,14 @@ beforeEach(() => { query.isError = true; query.isLoading = false; query.error.data.code = 'INTERNAL_SERVER_ERROR'; + routeParams.current = { + owner: 'org', + repo: 'repo', + number: '1', + path: 'src/a.ts', + line: '1', + side: 'RIGHT', + }; vi.clearAllMocks(); }); @@ -146,6 +172,35 @@ describe('PR Overview full-body states', () => { } ); + it('gives a provider reconnect its own notice and recovery CTA instead of the GitHub empty state', async () => { + query.error.data.code = 'PRECONDITION_FAILED'; + const scope: ProviderPrScope = { + ref: { platform: 'gitlab', projectPath: 'group/repo', mrIid: 1 }, + organizationId: null, + }; + const { renderer, unmount } = await renderWithProviders( + + {createElement(PrReviewOverview, overviewProps)} + + ); + + // Not the GitHub-only centered empty state. + expect(renderer.root.findAll(node => String(node.type) === 'EmptyState')).toHaveLength(0); + const texts = renderer.root + .findAllByType('Text' as never) + .flatMap(node => node.children) + .filter((child): child is string => typeof child === 'string'); + expect(texts).toContain('GitLab connection expired'); + // The recovery CTA is present, not hidden behind the GitHub-only arm. + expect( + renderer.root.findAll( + node => + String(node.type) === 'Button' && node.props.accessibilityLabel === 'Check connection' + ) + ).toHaveLength(1); + unmount(); + }); + it('keeps cached overview content and its refresh control after a transient failure', async () => { query.data = { title: 'Saved title', @@ -199,3 +254,22 @@ describe.each([ unmount(); }); }); + +describe('composer malformed route', () => { + it('renders the terminal invalid state without the Add-comment chrome', async () => { + // A hand-built or restored composer link with no valid comment target + // failed at the ROUTE, not at the comment: the sheet must not announce + // "Add comment" over a "Page not found" body. + routeParams.current = { owner: 'org', repo: 'repo', number: '1' }; + const { renderer, unmount } = await renderWithProviders( + createElement(PrReviewCommentComposerScreen) + ); + expect(renderer.root.findAll(node => String(node.type) === 'InvalidRouteState')).toHaveLength( + 1 + ); + expect(renderer.root.findAll(node => String(node.type) === 'PrFormSheetHeader')).toHaveLength( + 0 + ); + unmount(); + }); +}); diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx index 4c6539c888..e8042acbc3 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx @@ -16,12 +16,40 @@ import { type PrOverviewDto, } from '@/lib/pr-review/merge/merge-blocked-reasons'; -export function TerminalChip({ state }: Readonly<{ state: PrOverviewDto['state'] }>) { +// Mid-sentence lowercase noun (common.*) vs the capitalized standalone +// label (prReview.terms.*) — see providerPrTermKey. +// i18n-dup-ok: one copy, two senses: mid-sentence lowercase noun vs +// capitalized standalone label; languages case-decline them apart. +type TerminalNounKey = 'common.mergeRequest' | 'common.pullRequest'; + +function terminalLabelKey( + state: PrOverviewDto['state'], + nounKey: TerminalNounKey +): + | 'prReview.merge.terminal.alreadyMerged' + | 'prReview.merge.terminal.closedMergeRequest' + | 'prReview.merge.terminal.closed' { + if (state === 'merged') { + return 'prReview.merge.terminal.alreadyMerged'; + } + return nounKey === 'common.mergeRequest' + ? 'prReview.merge.terminal.closedMergeRequest' + : 'prReview.merge.terminal.closed'; +} + +export function TerminalChip({ + state, + nounKey = 'common.pullRequest', +}: Readonly<{ + state: PrOverviewDto['state']; + /** + * The provider's own noun for the closed sentence (s6): a GitLab merge + * request says "merge request", GitHub and Bitbucket say "pull request". + */ + nounKey?: TerminalNounKey; +}>) { const { t } = useTranslation(); - const label = - state === 'merged' - ? t('prReview.merge.terminal.alreadyMerged') - : t('prReview.merge.terminal.closed'); + const label = t(terminalLabelKey(state, nounKey)); return ( diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-section-provider.test.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-section-provider.test.tsx new file mode 100644 index 0000000000..2f07d94580 --- /dev/null +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-section-provider.test.tsx @@ -0,0 +1,166 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as comment-row.test.tsx) */ +// The provider merge section (s6): the overview's merge affordance on a +// GitLab MR / Bitbucket PR. The merge CTA always pushes the ref's own sheet +// route (the sheet renders the s2/s3 restrictions); the auto-merge row +// follows the capability list — GitLab gets the enable CTA, Bitbucket gets +// the explicit capability banner instead of a dead button or a silent +// absence. A terminal PR renders the terminal chip and no CTAs. + +import * as React from 'react'; +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import '@/i18n'; + +import { PrMergeSectionProvider } from './pr-merge-section-provider'; +import { type ProviderPrRef } from '@/lib/pr-review/provider-pr-ref'; + +const routerPush = vi.fn(); + +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: routerPush }), +})); + +vi.mock('react-native', () => ({ + View: 'View', + ActivityIndicator: 'ActivityIndicator', +})); + +vi.mock('@/components/ui/icons', () => ({ + AlertTriangle: 'AlertTriangle', + GitBranch: 'GitBranch', + GitMerge: 'GitMerge', + GitPullRequest: 'GitPullRequest', + RefreshCw: 'RefreshCw', + ShieldAlert: 'ShieldAlert', + XCircle: 'XCircle', +})); + +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +// The section parts render the UI spinner; the real one reaches the motion +// policy (expo-battery), which stays unmocked in this pure harness. +vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' })); + +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ + primaryForeground: '#FFFFFF', + foreground: '#000000', + mutedForeground: '#6F6A61', + destructive: '#DC2626', + }), +})); + +const GITLAB_REF: ProviderPrRef = { + platform: 'gitlab', + projectPath: 'group/sub/repo', + mrIid: 12, +}; +const BITBUCKET_REF: ProviderPrRef = { + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'api', + prId: 42, +}; + +async function mount( + prRef: ProviderPrRef, + state: 'open' | 'closed' | 'merged' +): Promise { + let renderer: TestRenderer.ReactTestRenderer | null = null; + await act(async () => { + await Promise.resolve(); + renderer = TestRenderer.create(createElement(PrMergeSectionProvider, { prRef, state })); + }); + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- the closure assignment cannot cross into TS's narrow + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +function findButtons(renderer: TestRenderer.ReactTestRenderer, label: string): number { + return renderer.root.findAll( + node => + String(node.type) === 'Button' && + (node.props as Record).accessibilityLabel === label + ).length; +} + +function findButton(renderer: TestRenderer.ReactTestRenderer, label: string) { + const button = renderer.root.find( + node => + String(node.type) === 'Button' && + (node.props as Record).accessibilityLabel === label + ); + return (button.props as { onPress?: () => void }).onPress; +} + +describe('PrMergeSectionProvider (s6)', () => { + beforeEach(() => { + routerPush.mockClear(); + }); + + it('offers merge and enable-auto-merge on a GitLab merge request (capability supported)', async () => { + const renderer = await mount(GITLAB_REF, 'open'); + expect(findButtons(renderer, 'Merge merge request')).toBe(1); + expect(findButtons(renderer, 'Enable auto-merge')).toBe(1); + renderer.unmount(); + }); + + it('offers merge with the explicit capability banner on Bitbucket (auto-merge unsupported)', async () => { + const renderer = await mount(BITBUCKET_REF, 'open'); + expect(findButtons(renderer, 'Merge pull request')).toBe(1); + expect(findButtons(renderer, 'Enable auto-merge')).toBe(0); + const banner = renderer.root.find( + node => + typeof node.type === 'function' && + (node.type as { name?: string }).name === 'PrReviewCapabilityBanner' + ); + expect( + (banner.props as { capability: { supported: boolean; reason: string } }).capability + ).toEqual({ + supported: false, + reason: 'Bitbucket Cloud does not expose auto-merge in its API', + }); + renderer.unmount(); + }); + + it('pushes the merge sheet inside the GitLab ref route on press', async () => { + const renderer = await mount(GITLAB_REF, 'open'); + act(() => { + findButton(renderer, 'Merge merge request')?.(); + }); + expect(routerPush).toHaveBeenCalledWith( + '/(app)/pr-review/gitlab/group/sub/repo/12/merge?mode=merge' + ); + renderer.unmount(); + }); + + it('pushes the auto-merge arm inside the ref route on the enable CTA', async () => { + const renderer = await mount(GITLAB_REF, 'open'); + act(() => { + findButton(renderer, 'Enable auto-merge')?.(); + }); + expect(routerPush).toHaveBeenCalledWith( + '/(app)/pr-review/gitlab/group/sub/repo/12/merge?mode=enable-auto-merge' + ); + renderer.unmount(); + }); + + it.each<['closed' | 'merged', string]>([ + ['merged', 'Already merged'], + ['closed', 'This merge request is closed'], + ])( + 'renders only the terminal chip with provider wording on a %s GitLab merge request', + async (state, label) => { + const renderer = await mount(GITLAB_REF, state); + expect(renderer.root.findAll(node => String(node.type) === 'Button')).toHaveLength(0); + expect( + renderer.root.findAll(node => String(node.type) === 'Text' && node.props.children === label) + ).toHaveLength(1); + renderer.unmount(); + } + ); +}); diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-section-provider.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-section-provider.tsx new file mode 100644 index 0000000000..cdd9093068 --- /dev/null +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-section-provider.tsx @@ -0,0 +1,82 @@ +// The provider merge section (s6). The GitHub section derives its gate from +// the GitHub overview DTO; a GitLab MR / Bitbucket PR normalizes `mergeable` +// to null, so this arm offers the merge affordance directly and lets the +// confirmation sheet — which reads `providerReview.getMergeState` — render +// the restrictions list and refuse the submit. Auto-merge follows the +// capability list: GitLab (supported) gets the enable CTA, Bitbucket gets +// the explicit capability banner with the provider's reason — never a dead +// button and never a silent absence. + +import { useRouter } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { View } from 'react-native'; + +import { PrReviewCapabilityBanner } from '@/components/pr-review/pr-review-capability-banner'; +import { TerminalChip } from '@/components/pr-review/merge/pr-merge-section-parts'; +import { providerPrNounKey } from '@/components/pr-review/pr-review-provider-noun'; +import { providerPrSheetHref } from '@/components/pr-review/pr-review-provider-sheet-href'; +import { Button } from '@/components/ui/button'; +import { GitMerge } from '@/components/ui/icons'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { providerPrCapabilities, type ProviderPrRef } from '@/lib/pr-review/provider-pr-ref'; + +type PrMergeSectionProviderProps = Readonly<{ + /** The provider ref the section pushes its sheet route under. */ + prRef: ProviderPrRef; + /** The overview lifecycle state; `open` is the only mergeable one. */ + state: 'open' | 'closed' | 'merged'; +}>; + +export function PrMergeSectionProvider({ prRef, state }: PrMergeSectionProviderProps) { + const router = useRouter(); + const colors = useThemeColors(); + const { t } = useTranslation(); + + if (state !== 'open') { + // Provider wording (s6): a closed GitLab merge request says "merge + // request"; Bitbucket keeps "pull request" — that is its own noun. + return ; + } + + const autoMerge = providerPrCapabilities(prRef.platform).autoMerge; + const mergeLabel = t('prReview.merge.mergeTermTitle', { + term: t(providerPrNounKey(prRef.platform)), + }); + + return ( + + + {t('prReview.merge.merge')} + + + {autoMerge.supported ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.test.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.test.tsx new file mode 100644 index 0000000000..e73c0272cf --- /dev/null +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.test.tsx @@ -0,0 +1,121 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom); see src/lib/pr-review/pending-review-provider.mounted.test.tsx */ +// MergeSheetFormBody field contract (s6f): the Bitbucket merge arm passes +// showTitle=false because the provider merge takes only the message — no +// commit-title input may exist on that arm whose value would be silently +// dropped on submit. The GitLab and GitHub arms keep the field. + +import * as React from 'react'; +import TestRenderer from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import '@/i18n'; +import type * as ReactI18next from 'react-i18next'; +import { MergeSheetFormBody } from './pr-merge-sheet-parts'; +import { type AllowedMergeMethod } from '@/lib/pr-review/merge/merge-blocked-reasons'; + +vi.mock('react-i18next', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => { + const i18n = actual.getI18n(); + return { t: i18n.t.bind(i18n), i18n }; + }, + }; +}); + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + Switch: 'Switch', + TextInput: 'TextInput', + View: 'View', +})); + +vi.mock('expo-haptics', () => ({ + selectionAsync: vi.fn(), +})); + +vi.mock('@/components/pr-review/pr-form-sheet-chrome', () => ({ + PrFormSheetFooter: 'PrFormSheetFooter', + useFormSheetKeyboardVisible: () => false, +})); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/radio-group', () => ({ + RadioGroup: 'RadioGroup', + radioItemA11y: (props: unknown) => props, +})); +vi.mock('@/components/ui/accessible-status', () => ({ AccessibleStatus: 'AccessibleStatus' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#000000' }), +})); +vi.mock('@/components/pr-review/pr-review-reconnect-notice', () => ({ + PrReviewReconnectNotice: 'PrReviewReconnectNotice', +})); + +function formBodyProps(showTitle: boolean) { + const titleRef = { current: 'Merge pull request #1 from Feature' }; + const messageRef = { current: '' }; + return { + noMethodsAllowed: false, + methodOptions: [{ value: 'merge' as AllowedMergeMethod, label: 'Merge', icon: 'merge' }], + method: 'merge' as AllowedMergeMethod, + isMutating: false, + onMethodChange: vi.fn(), + titleRef, + titleInputRef: { current: null }, + titlePlaceholder: 'Merge pull request #1 from Feature', + showTitle, + messageRef, + messageInputRef: { current: null }, + isHalfDetent: false, + showDeleteBranchToggle: false, + deleteBranch: false, + onDeleteBranchChange: vi.fn(), + inlineError: null, + inlineErrorKind: null, + submitLabel: 'Merge', + onConfirm: vi.fn(), + onDismiss: vi.fn(), + }; +} + +function inputByA11yLabel( + renderer: TestRenderer.ReactTestRenderer, + label: string +): TestRenderer.ReactTestInstance | null { + const found = renderer.root.findAll(node => node.props.accessibilityLabel === label); + return found[0] ?? null; +} + +describe('MergeSheetFormBody commit-title field (s6f)', () => { + it('renders no commit-title input when showTitle is false (Bitbucket arm)', () => { + let renderer: TestRenderer.ReactTestRenderer | undefined = undefined; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- the guard proves it, the cast cannot + if (!renderer) { + throw new Error('Failed to mount MergeSheetFormBody'); + } + expect(inputByA11yLabel(renderer, 'Commit title')).toBeNull(); + // The message field stays: the Bitbucket merge takes the message. + expect(inputByA11yLabel(renderer, 'Commit message')).not.toBeNull(); + }); + + it('keeps the commit-title input when showTitle is true (GitLab and GitHub arms)', () => { + let renderer: TestRenderer.ReactTestRenderer | undefined = undefined; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- the guard proves it, the cast cannot + if (!renderer) { + throw new Error('Failed to mount MergeSheetFormBody'); + } + const title = inputByA11yLabel(renderer, 'Commit title'); + if (!title) { + throw new Error('Commit title input not found'); + } + expect(title.props.defaultValue).toBe('Merge pull request #1 from Feature'); + }); +}); diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx index a578c3529e..2d4b1d7449 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx @@ -205,6 +205,12 @@ export function MergeSheetFormBody(props: { titleRef: RefObject; titleInputRef: RefObject; titlePlaceholder: string; + /** + * False on the Bitbucket merge arm (s6f): the provider merge takes only + * the message, so no commit-title input exists whose value would be + * silently dropped on submit. + */ + showTitle: boolean; messageRef: RefObject; messageInputRef: RefObject; isHalfDetent: boolean; @@ -226,6 +232,7 @@ export function MergeSheetFormBody(props: { titleRef, titleInputRef, titlePlaceholder, + showTitle, messageRef, messageInputRef, isHalfDetent, @@ -260,12 +267,14 @@ export function MergeSheetFormBody(props: { onChange={onMethodChange} /> )} - + {showTitle ? ( + + ) : null} ({ error: null as Error | null, })); +// Every useState setter the mocked hook primitives hand out, in call order. +// The error effect writes its inline copy through one of them, so a test can +// observe the state write even though the no-op mock never re-renders. +const stateSetters = vi.hoisted(() => [] as { mock: { calls: unknown[][] } }[]); + vi.mock('react', async () => { const actual = await vi.importActual('react'); return { ...actual, - useState: vi.fn( - (initial: T) => [initial, vi.fn() as () => void] as [T, (value: T) => void] - ), + useState: vi.fn((initial: T) => { + const setter = vi.fn(); + stateSetters.push(setter); + return [initial, setter as () => void] as [T, (value: T) => void]; + }), useMemo: vi.fn((factory: () => T) => factory()), useRef: vi.fn((initial: T) => { const ref: React.RefObject = { current: initial }; @@ -57,14 +75,17 @@ vi.mock('react', async () => { }; }); +const alertCalls = vi.hoisted(() => [] as { title: string; message: string }[]); + vi.mock('react-native', () => ({ Alert: { alert: vi.fn( ( - _title: string, - _message: string, + title: string, + message: string, buttons: readonly { style?: string; onPress?: () => void }[] ) => { + alertCalls.push({ title, message }); const destructive = buttons.find(b => b.style === 'destructive'); destructive?.onPress?.(); } @@ -117,6 +138,7 @@ vi.mock('@/components/pr-review/pr-review-reconnect-notice', () => ({ })); vi.mock('@/components/pr-review/pr-form-sheet-chrome', () => ({ PrFormSheetHeader: 'PrFormSheetHeader', + PrFormSheetFooter: 'PrFormSheetFooter', })); vi.mock('@/components/pr-review/merge/pr-merge-icons', () => ({ defaultMergeMethodOptionFor: () => 'squash', @@ -239,7 +261,7 @@ function findElement({ node, type, prop, value }: FindElementArgs): React.ReactE return null; } -function pressMerge(props: typeof baseProps) { +function pressMerge(props: Parameters[0]) { // eslint-disable-next-line new-cap const element = PrMergeSheet(props); // The submit CTA lives inside MergeSheetFormBody (mocked as a string @@ -388,3 +410,535 @@ describe('PrMergeSheet performSubmit wiring (P0-B-08)', () => { expect(onDismiss).toHaveBeenCalledTimes(1); }); }); + +// ── Provider arms (s6) ─────────────────────────────────────────────── + +/** Find an element whose type is a real (unmocked) component — by identity. */ +function findComponent(node: unknown, component: unknown): React.ReactElement | null { + if (React.isValidElement(node)) { + if (node.type === component) { + return node; + } + const children = (node.props as Record).children; + const found = findComponent(children, component); + if (found) { + return found; + } + } + if (Array.isArray(node)) { + for (const child of node) { + const found = findComponent(child, component); + if (found) { + return found; + } + } + } + return null; +} + +/** findComponent, but fails the test loudly when the component is absent. */ +function requireComponent(node: unknown, component: unknown): React.ReactElement { + const found = findComponent(node, component); + if (!found) { + throw new Error(`component ${(component as { name?: string }).name ?? '?'} not found`); + } + return found; +} + +/** Every string a directly-invoked component function rendered, in order. */ +function collectTexts(node: unknown): string[] { + const out: string[] = []; + const walk = (value: unknown): void => { + if (typeof value === 'string') { + out.push(value); + return; + } + if (Array.isArray(value)) { + for (const child of value) { + walk(child); + } + return; + } + if (React.isValidElement(value)) { + walk((value.props as Record).children); + } + }; + walk(node); + return out; +} + +const GITLAB_REF: ProviderPrRef = { platform: 'gitlab', projectPath: 'octocat/hello', mrIid: 1 }; +const BITBUCKET_REF: ProviderPrRef = { + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'hello', + prId: 1, +}; + +function mergeState(overrides: Partial = {}): ProviderPrMergeState { + return { + canMerge: true, + approvalsRequired: 0, + pipelineMustSucceed: false, + conflicts: false, + blockedReasons: [], + ...overrides, + }; +} + +const AUTO_MERGE_SUPPORTED: ProviderReviewCapability = { supported: true, reason: '' }; +const AUTO_MERGE_UNSUPPORTED: ProviderReviewCapability = { + supported: false, + reason: 'Bitbucket Cloud does not expose auto-merge in its API', +}; + +describe('PrMergeSheet provider merge arm (s6)', () => { + beforeEach(() => { + alertCalls.length = 0; + __resetMergePartialSuccessStoreForTests(); + mergeMutationMocks.mutateAsync.mockReset(); + mergeMutationMocks.isPending = false; + mergeMutationMocks.error = null; + autoMergeMutationMocks.mutateAsync.mockReset(); + autoMergeMutationMocks.isPending = false; + autoMergeMutationMocks.error = null; + vi.clearAllMocks(); + }); + + it('fences the merge on the head and folds the method into squash for GitLab', () => { + mergeMutationMocks.mutateAsync.mockResolvedValueOnce({ + merged: true, + sha: 'mergedsha', + branchDeleted: true, + }); + pressMerge({ ...baseProps, prRef: GITLAB_REF, mergeState: mergeState() }); + + // The confirm dialog speaks the connected provider's noun, in sentence + // form: "Merge merge request?", never "Merge Merge request?". + expect(alertCalls[0]).toEqual({ + title: 'Merge merge request?', + message: 'This will merge your changes into the base branch.', + }); + expect(mergeMutationMocks.mutateAsync).toHaveBeenCalledWith({ + expectedHeadSha: 'a'.repeat(40), + squash: true, + deleteBranch: true, + // The commit-title field rides the merge: GitLab takes the title the + // user sees (here the mocked default). + commitTitle: 'Merge pull request #1 from Feature', + }); + }); + + it('keeps the GitHub arm confirm copy and full input unchanged', () => { + mergeMutationMocks.mutateAsync.mockResolvedValueOnce({ + merged: true, + sha: 'mergedsha', + branchDeleted: true, + }); + pressMerge(baseProps); + + expect(alertCalls[0]?.title).toBe('Merge pull request?'); + expect(mergeMutationMocks.mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + owner: 'octocat', + repo: 'hello', + number: 1, + method: 'squash', + expectedHeadSha: 'a'.repeat(40), + }) + ); + }); + + it('folds the provider ref identity into the durable merge draft key', async () => { + mergeMutationMocks.mutateAsync.mockResolvedValueOnce({ + merged: true, + sha: 'mergedsha', + branchDeleted: true, + }); + pressMerge({ ...baseProps, prRef: GITLAB_REF, mergeState: mergeState() }); + await flushMicrotasks(); + + expect(clearDraft).toHaveBeenCalledWith( + 'u1', + `pr-merge:octocat/hello#1@${providerPrRefKey(GITLAB_REF)}` + ); + }); + + it('shows the restrictions list above the form when the merge is allowed', () => { + // eslint-disable-next-line new-cap + const element = PrMergeSheet({ + ...baseProps, + prRef: GITLAB_REF, + mergeState: mergeState({ approvalsRequired: 2, pipelineMustSucceed: true }), + }); + expect( + findElement({ + node: element, + type: 'MergeSheetFormBody', + prop: 'submitLabel', + value: 'Merge', + }) + ).not.toBeNull(); + expect(findComponent(element, MergeRestrictionsList)).not.toBeNull(); + }); + + it('replaces the form with the restrictions list when the merge is blocked', () => { + // eslint-disable-next-line new-cap + const element = PrMergeSheet({ + ...baseProps, + prRef: GITLAB_REF, + mergeState: mergeState({ + canMerge: false, + blockedReasons: [{ code: 'failing_pipeline', message: 'Pipeline #123 failed' }], + }), + }); + // Nothing to submit: the form is gone and only Cancel remains. + expect( + findElement({ + node: element, + type: 'MergeSheetFormBody', + prop: 'submitLabel', + value: 'Merge', + }) + ).toBeNull(); + expect(findComponent(element, MergeRestrictionsList)).not.toBeNull(); + expect( + findElement({ node: element, type: 'Button', prop: 'accessibilityLabel', value: 'Cancel' }) + ).not.toBeNull(); + }); + + it('pins the blocked-arm footer to the sheet bottom (spot check e7)', () => { + // eslint-disable-next-line new-cap + const element = PrMergeSheet({ + ...baseProps, + prRef: GITLAB_REF, + mergeState: mergeState({ + canMerge: false, + blockedReasons: [{ code: 'failing_pipeline', message: 'Pipeline #123 failed' }], + }), + }); + // The sheet opens at the full detent; a growing spacer plus a content + // container that fills the viewport keep Cancel on the sheet's bottom + // edge instead of floating mid-sheet over an empty region. + const scroll = findElement({ + node: element, + type: 'ScrollView', + prop: 'className', + value: 'flex-1 bg-background', + }); + expect(scroll).not.toBeNull(); + if (!scroll) { + return; + } + expect( + (scroll.props as { contentContainerStyle?: Record }).contentContainerStyle + ).toEqual({ flexGrow: 1, paddingBottom: 4 }); + expect( + findElement({ node: scroll, type: 'View', prop: 'className', value: 'flex-1' }) + ).not.toBeNull(); + }); +}); + +// ── s6f: reviewer blocking findings ────────────────────────────────── + +function forbiddenError(): Error { + return Object.assign(new Error('403 Forbidden'), { data: { code: 'FORBIDDEN' } }); +} + +/** True when the error effect wrote `value` into any state slot. */ +function stateValueWritten(value: string): boolean { + return stateSetters.some(setter => setter.mock.calls.some(call => call[0] === value)); +} + +describe('PrMergeSheet commit-title arm (s6f)', () => { + beforeEach(() => { + stateSetters.length = 0; + mergeMutationMocks.mutateAsync.mockReset(); + mergeMutationMocks.isPending = false; + mergeMutationMocks.error = null; + vi.clearAllMocks(); + }); + + function formBodyProps(props: Parameters[0]) { + // eslint-disable-next-line new-cap + const element = PrMergeSheet(props); + const formBody = findElement({ + node: element, + type: 'MergeSheetFormBody', + prop: 'submitLabel', + value: 'Merge', + }); + if (!formBody) { + throw new Error('MergeSheetFormBody not found in rendered tree'); + } + return formBody.props as { showTitle?: boolean }; + } + + it('hides the commit-title field on the Bitbucket arm (the provider takes only the message)', () => { + expect( + formBodyProps({ ...baseProps, prRef: BITBUCKET_REF, mergeState: mergeState() }).showTitle + ).toBe(false); + }); + + it('keeps the commit-title field on the GitLab and GitHub arms', () => { + expect( + formBodyProps({ ...baseProps, prRef: GITLAB_REF, mergeState: mergeState() }).showTitle + ).toBe(true); + expect(formBodyProps(baseProps).showTitle).toBe(true); + }); + + it('never carries a commitTitle on the Bitbucket merge input', () => { + mergeMutationMocks.mutateAsync.mockResolvedValueOnce({ + merged: true, + sha: 'mergedsha', + branchDeleted: true, + }); + pressMerge({ ...baseProps, prRef: BITBUCKET_REF, mergeState: mergeState() }); + expect(mergeMutationMocks.mutateAsync).toHaveBeenCalledWith( + expect.not.objectContaining({ commitTitle: expect.anything() }) + ); + }); +}); + +describe('PrMergeSheet refused-merge wording (s6f)', () => { + beforeEach(() => { + stateSetters.length = 0; + mergeMutationMocks.mutateAsync.mockReset(); + mergeMutationMocks.isPending = false; + mergeMutationMocks.error = null; + vi.clearAllMocks(); + }); + + it('words a refused GitLab merge after the merge-request noun', () => { + mergeMutationMocks.error = forbiddenError(); + // eslint-disable-next-line new-cap + PrMergeSheet({ ...baseProps, prRef: GITLAB_REF, mergeState: mergeState() }); + // The provider 403 must never read "pull request" on a merge request. + expect(stateValueWritten("You don't have permission to merge this merge request.")).toBe(true); + expect(stateValueWritten("You don't have permission to merge this pull request.")).toBe(false); + }); + + it('keeps the exact pre-s6 forbidden copy on the GitHub arm', () => { + mergeMutationMocks.error = forbiddenError(); + // eslint-disable-next-line new-cap + PrMergeSheet(baseProps); + expect(stateValueWritten("You don't have permission to merge this pull request.")).toBe(true); + }); +}); + +describe('PrMergeSheet provider auto-merge arms (s6)', () => { + beforeEach(() => { + alertCalls.length = 0; + __resetMergePartialSuccessStoreForTests(); + mergeMutationMocks.mutateAsync.mockReset(); + autoMergeMutationMocks.mutateAsync.mockReset(); + autoMergeMutationMocks.isPending = false; + autoMergeMutationMocks.error = null; + vi.clearAllMocks(); + }); + + function autoMergeProps(ref: ProviderPrRef, capability: ProviderReviewCapability) { + return { + ...baseProps, + mode: 'enable-auto-merge' as const, + prRef: ref, + mergeState: mergeState({ approvalsRequired: 2 }), + autoMergeCapability: capability, + }; + } + + function pressSubmit(element: React.ReactElement): () => void { + const submit = findElement({ + node: element, + type: 'Button', + prop: 'accessibilityLabel', + value: 'Enable auto-merge', + }); + if (!submit) { + throw new Error('auto-merge submit button not found'); + } + const onPress = (submit.props as { onPress?: () => void }).onPress; + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- the guard above proves it, the cast cannot + if (!onPress) { + throw new Error('auto-merge submit has no onPress'); + } + return onPress; + } + + it('arms GitLab auto-merge through the head fence with the provider confirm copy', () => { + autoMergeMutationMocks.mutateAsync.mockResolvedValueOnce({ supported: true, reason: '' }); + // eslint-disable-next-line new-cap + const element = PrMergeSheet(autoMergeProps(GITLAB_REF, AUTO_MERGE_SUPPORTED)); + expect(findComponent(element, ProviderAutoMergeBody)).not.toBeNull(); + + pressSubmit(element)(); + expect(alertCalls[0]?.message).toBe( + 'The merge request will merge automatically once its pipeline succeeds.' + ); + expect(autoMergeMutationMocks.mutateAsync).toHaveBeenCalledWith({ + expectedHeadSha: 'a'.repeat(40), + }); + }); + + it('shows the capability banner with the provider reason for Bitbucket, with nothing to submit', () => { + // eslint-disable-next-line new-cap + const element = PrMergeSheet(autoMergeProps(BITBUCKET_REF, AUTO_MERGE_UNSUPPORTED)); + // The banner carries the server's explicit reason; no submit CTA exists. + const banner = requireComponent(element, PrReviewCapabilityBanner); + expect((banner.props as { capability?: ProviderReviewCapability }).capability).toBe( + AUTO_MERGE_UNSUPPORTED + ); + expect( + findElement({ + node: element, + type: 'Button', + prop: 'accessibilityLabel', + value: 'Enable auto-merge', + }) + ).toBeNull(); + expect(autoMergeMutationMocks.mutateAsync).not.toHaveBeenCalled(); + }); +}); + +describe('MergeRestrictionsList (s6)', () => { + it('lists the policy flags in localized copy', () => { + // eslint-disable-next-line new-cap + const tree = MergeRestrictionsList({ + mergeState: mergeState({ + canMerge: false, + approvalsRequired: 2, + pipelineMustSucceed: true, + conflicts: true, + }), + term: 'merge request', + }); + const texts = collectTexts(tree); + expect(texts).toContain('Merge restrictions'); + expect(texts).toContain('Resolve the merge conflicts on this branch before merging.'); + expect(texts).toContain('2 approvals required'); + expect(texts).toContain('The pipeline must succeed before merging.'); + }); + + it('localizes known provider reasons and keeps the server message for `other`', () => { + // eslint-disable-next-line new-cap + const tree = MergeRestrictionsList({ + mergeState: mergeState({ + canMerge: false, + blockedReasons: [ + { code: 'failing_pipeline', message: 'Pipeline #123 failed' }, + { code: 'pending_pipeline', message: 'Pipeline #124 running' }, + { code: 'permission', message: '403 Forbidden' }, + { code: 'other', message: 'A merge is already running' }, + ], + }), + term: 'merge request', + }); + const texts = collectTexts(tree); + expect(texts).toContain('The pipeline is failing on the latest commit.'); + expect(texts).toContain('The pipeline is still running on the latest commit.'); + expect(texts).toContain("You don't have permission to merge this merge request."); + // The `other` code keeps the provider's own message verbatim. + expect(texts).toContain('A merge is already running'); + }); + + it('does not repeat a flag the reasons list already carries', () => { + // eslint-disable-next-line new-cap + const tree = MergeRestrictionsList({ + mergeState: mergeState({ + canMerge: false, + approvalsRequired: 3, + pipelineMustSucceed: true, + conflicts: true, + blockedReasons: [ + { code: 'conflicts', message: 'conflicts exist' }, + { code: 'failing_pipeline', message: 'Pipeline #123 failed' }, + ], + }), + term: 'merge request', + }); + const texts = collectTexts(tree); + const conflictRows = texts.filter( + text => text === 'Resolve the merge conflicts on this branch before merging.' + ); + expect(conflictRows).toHaveLength(1); + // The pipeline flag row is suppressed: the failing-pipeline reason says it. + expect(texts).not.toContain('The pipeline must succeed before merging.'); + expect(texts).toContain('3 approvals required'); + }); + + it('maps the draft reason onto the provider noun', () => { + // eslint-disable-next-line new-cap + const tree = MergeRestrictionsList({ + mergeState: mergeState({ + canMerge: false, + blockedReasons: [{ code: 'draft', message: 'Draft status' }], + }), + term: 'merge request', + }); + expect(collectTexts(tree)).toContain( + 'Mark the merge request as ready for review before merging.' + ); + }); + + it('renders nothing when no restriction applies', () => { + // eslint-disable-next-line new-cap + expect(MergeRestrictionsList({ mergeState: mergeState(), term: 'merge request' })).toBeNull(); + }); +}); + +describe('ProviderAutoMergeBody (s6)', () => { + it('explains the arm in the provider noun and repeats the restrictions', () => { + // eslint-disable-next-line new-cap + const tree = ProviderAutoMergeBody({ + mergeState: mergeState({ approvalsRequired: 2 }), + term: 'merge request', + }); + // The restrictions render as a mounted MergeRestrictionsList child. + const restrictions = requireComponent(tree, MergeRestrictionsList); + expect( + (restrictions.props as { mergeState?: ProviderPrMergeState }).mergeState?.approvalsRequired + ).toBe(2); + expect(collectTexts(tree)).toContain( + 'GitLab merges this merge request automatically once its pipeline succeeds.' + ); + }); + + it('renders just the explanation while the merge state has not loaded', () => { + // eslint-disable-next-line new-cap + const tree = ProviderAutoMergeBody({ mergeState: null, term: 'merge request' }); + expect(collectTexts(tree)).toEqual([ + 'GitLab merges this merge request automatically once its pipeline succeeds.', + ]); + }); +}); + +function conflictError(message: string): Error { + return Object.assign(new Error(message), { data: { code: 'CONFLICT' } }); +} + +describe('staleHeadRejectionMessage (s6)', () => { + it('returns the provider reason for a moved head', () => { + const message = + 'The merge request changed since it was loaded. Reload the merge request and try again.'; + expect(staleHeadRejectionMessage(conflictError(message))).toBe(message); + }); + + it('returns the reason for a target closed without merging', () => { + const message = 'The merge request was closed without merging.'; + expect(staleHeadRejectionMessage(conflictError(message))).toBe(message); + }); + + it('leaves other conflicts to the generic classification', () => { + expect(staleHeadRejectionMessage(conflictError('A merge is already running'))).toBeNull(); + }); + + it('ignores errors that are not conflicts', () => { + expect( + staleHeadRejectionMessage( + Object.assign(new Error('The merge request changed since it was loaded.'), { + data: { code: 'FORBIDDEN' }, + }) + ) + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx index 2de1156233..d7b63c5ec1 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx @@ -14,19 +14,36 @@ // dismisses (cancel) or the mutation succeeds (auto-dismiss). import * as Haptics from 'expo-haptics'; -import { Alert, Keyboard, ScrollView, type TextInput, useWindowDimensions } from 'react-native'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Alert, + Keyboard, + ScrollView, + type TextInput, + useWindowDimensions, + View, +} from 'react-native'; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { type inferRouterInputs, type MobileRouter } from '@kilocode/trpc/mobile'; +import { + type ProviderPrMergeBlockedReason, + type ProviderPrMergeState, + type ProviderPrPlatform, + type ProviderReviewCapability, +} from '@kilocode/app-shared/provider-review'; -import { PrFormSheetHeader } from '@/components/pr-review/pr-form-sheet-chrome'; +import { PrFormSheetFooter, PrFormSheetHeader } from '@/components/pr-review/pr-form-sheet-chrome'; +import { PrReviewCapabilityBanner } from '@/components/pr-review/pr-review-capability-banner'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; import { type AllowedMergeMethod, type PrMergeMethod, type PrOverviewRepoSettings, } from '@/lib/pr-review/merge/merge-blocked-reasons'; import { + type EnableAutoMergeVars, + type MergeVars, useEnableAutoMergeMutation, useMergePullRequestMutation, } from '@/lib/pr-review/merge/use-pr-merge-mutations'; @@ -41,17 +58,26 @@ import { mergeMethodOptionsFor, } from '@/components/pr-review/merge/pr-merge-icons'; import { MergeSheetFormBody } from '@/components/pr-review/merge/pr-merge-sheet-parts'; +import { providerPrNounKey } from '@/components/pr-review/pr-review-provider-noun'; import { defaultCommitMessage, defaultCommitTitle, } from '@/lib/pr-review/merge/merge-commit-defaults'; import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; +import { formatNumber } from '@/lib/format'; +import { i18n } from '@/i18n'; +import { type ProviderPrRef, providerPrRefKey } from '@/lib/pr-review/provider-pr-ref'; +import { readTrpcErrorField } from '@/lib/trpc-error'; import { clearDraft, isMergeDraft, prMergeDraftKey, saveDraft } from '@/lib/persist/drafts'; import { useDraftFlushOnBackground } from '@/lib/persist/use-draft-flush'; import { useFencedDraftLoad } from '@/lib/persist/use-draft-load'; type PrMergeSheetMode = 'merge' | 'enable-auto-merge'; +// flexGrow makes the ScrollView's content container at least the viewport +// tall, so a short arm's spacer can push its footer to the sheet's bottom. +const SHEET_SCROLL_CONTENT_STYLE = { flexGrow: 1, paddingBottom: 4 }; + type PrMergeSheetProps = Readonly<{ owner: string; /** The GitHub repository name (the `repo` path segment, not the settings object). */ @@ -69,15 +95,67 @@ type PrMergeSheetProps = Readonly<{ mode: PrMergeSheetMode; sheetTitle: string; eyebrow: string; + /** + * The provider ref (s6). Present on the GitLab/Bitbucket surface: the merge + * posts through `providerReview.mergePullRequest` with the head fence, the + * method list comes from the provider (not the GitHub repo settings), and + * the draft key folds the ref identity. Absent on GitHub, which keeps the + * exact pre-s6 path. + */ + prRef?: ProviderPrRef; + /** + * The provider merge gate from `providerReview.getMergeState` (s2/s3). + * Rendered as the restrictions list and gates the submit; null on GitHub, + * whose gate derives from the overview DTO in the merge section. + */ + mergeState?: ProviderPrMergeState | null; + /** + * The auto-merge capability (provider arms). A `supported: false` answer + * (Bitbucket) renders the explicit capability banner instead of the form. + */ + autoMergeCapability?: ProviderReviewCapability; /** Called after a successful merge / auto-merge enable so the orchestrator can refetch. */ onRefetch: () => Promise; /** Called when the user cancels or after a successful submit. */ onDismiss: () => void; }>; -type RouterInputs = inferRouterInputs; -type MergePullRequestInput = RouterInputs['githubPrReview']['mergePullRequest']; -type AutoMergeInput = RouterInputs['githubPrReview']['enableAutoMerge']; +/** + * The provider's own noun in sentence form (s6). Defined in + * `pr-review-provider-noun.ts` (shared with the overview's provider merge + * arm) and re-exported here for the sheet's callers. + */ +export { providerPrNounKey } from '@/components/pr-review/pr-review-provider-noun'; + +/** + * The explicit stale-head rejection (s6): the server refuses a merge whose + * head moved (or whose target closed) with CONFLICT carrying the provider's + * own reason. There is nothing to retry against the old head, so the sheet + * keeps that reason inline and stays put — no redirect, no retry affordance. + * Returns the reason to show, or null when the error is not a stale-head. + */ +export function staleHeadRejectionMessage(error: unknown): string | null { + if (readTrpcErrorField(error, 'code') !== 'CONFLICT') { + return null; + } + const message = error instanceof Error ? error.message : ''; + return /changed since it was loaded|closed without merging/.test(message) ? message : null; +} + +/** + * The inline copy for a refused merge (s6f): the provider arm words the + * refusal after the connected provider (merge request vs pull request) + * through the existing term-parameterized key; GitHub keeps the exact + * pre-s6 copy. + */ +function mergeForbiddenCopy( + platform: ProviderPrPlatform | undefined, + t: ReturnType['t'] +): string { + return platform + ? t('prReview.merge.providerBlocked.permission', { term: t(providerPrNounKey(platform)) }) + : t('prReview.merge.forbidden'); +} /** * Wraps an uncontrolled-input ref so every `.current` write (the parts file's @@ -98,6 +176,16 @@ function savingRef(target: { current: T }, onWrite: () => void) { }); } +// The short arms (blocked merge, capability banner, provider auto-merge) +// carry a small body; the sheet still opens at the full detent, so their +// footers are pinned to the sheet's bottom edge with a growing spacer +// (spot check e7: Cancel sat mid-sheet over a large empty region). The +// ScrollView's content container grows to at least the viewport, so the +// spacer only expands when the body is shorter than the sheet. +function bodySpacer() { + return ; +} + export function PrMergeSheet(props: PrMergeSheetProps) { const { owner, @@ -113,22 +201,45 @@ export function PrMergeSheet(props: PrMergeSheetProps) { mode, sheetTitle, eyebrow, + prRef, + mergeState, + autoMergeCapability, onRefetch, onDismiss, } = props; const { t } = useTranslation(); - const methodOptions = useMemo(() => mergeMethodOptionsFor(repoSettings), [repoSettings]); + // Provider arms derive the method list from the platform, not the GitHub + // repo settings: GitLab offers merge + squash, Bitbucket Cloud only the + // merge commit. GitHub keeps the repo-settings list unchanged. + const providerMethodOptions = useMemo(() => { + if (!prRef) { + return null; + } + return mergeMethodOptionsFor({ + ...repoSettings, + allowMergeCommit: true, + allowSquashMerge: prRef.platform === 'gitlab', + allowRebaseMerge: false, + allowAutoMerge: prRef.platform === 'gitlab', + }); + }, [prRef, repoSettings]); + const methodOptions = useMemo( + () => providerMethodOptions ?? mergeMethodOptionsFor(repoSettings), + [providerMethodOptions, repoSettings] + ); const safeInitial: AllowedMergeMethod = useMemo( () => methodOptions.find(o => o.value === initialMethod)?.value ?? - defaultMergeMethodOptionFor(repoSettings), - [initialMethod, methodOptions, repoSettings] + (providerMethodOptions + ? (providerMethodOptions[0]?.value ?? defaultMergeMethodOptionFor(repoSettings)) + : defaultMergeMethodOptionFor(repoSettings)), + [initialMethod, methodOptions, providerMethodOptions, repoSettings] ); const [method, setMethod] = useState(safeInitial); - const showDeleteBranchToggle = !isCrossRepo; + const showDeleteBranchToggle = prRef ? true : !isCrossRepo; const [deleteBranch, setDeleteBranch] = useState(repoSettings.deleteBranchOnMerge); // iOS uncontrolled-input pattern: store text in a ref via onChangeText, @@ -145,7 +256,10 @@ export function PrMergeSheet(props: PrMergeSheetProps) { // read while the user id is unknown. The inputs render only once the draft // settles, seeded from the stored value or today's defaults. const { userId, isLoading: isIdentityLoading } = useCurrentUserId(); - const mergeDraftKey = prMergeDraftKey(owner, repoName, number); + const positionDraftKey = prMergeDraftKey(owner, repoName, number); + // Provider arms fold the collision-free ref identity into the key (identity + // rule 17); the GitHub bytes stay exactly as stored before this slice. + const mergeDraftKey = prRef ? `${positionDraftKey}@${providerPrRefKey(prRef)}` : positionDraftKey; const draft = useFencedDraftLoad<{ title: string; message: string }>({ userId, isIdentityLoading, @@ -190,13 +304,16 @@ export function PrMergeSheet(props: PrMergeSheetProps) { [owner, repoName, number] ); - const mergeMutation = useMergePullRequestMutation(ref); - const enableAutoMergeMutation = useEnableAutoMergeMutation(ref); + const mergeMutation = useMergePullRequestMutation(prRef ?? ref); + const enableAutoMergeMutation = useEnableAutoMergeMutation(prRef ?? ref); const isMutating = (mode === 'merge' && mergeMutation.isPending) || (mode === 'enable-auto-merge' && enableAutoMergeMutation.isPending); const lastError = mode === 'merge' ? mergeMutation.error : enableAutoMergeMutation.error; + // The provider platform as a stable primitive: the error effect words a + // refusal after it without depending on the `prRef` object identity. + const providerPlatform = prRef?.platform; useEffect(() => { if (lastError) { @@ -207,11 +324,20 @@ export function PrMergeSheet(props: PrMergeSheetProps) { setInlineErrorKind('non-retryable'); return; } + // A moved head is the explicit stale-head rejection (s6): the server + // answers CONFLICT with the provider's own reason, there is nothing to + // retry against the old head, and the sheet stays put showing it. + const staleHead = staleHeadRejectionMessage(lastError); + if (staleHead !== null) { + setInlineError(staleHead); + setInlineErrorKind('non-retryable'); + return; + } const classification = classifyPrReviewMutationError(lastError); if (classification.kind === 'bad-request' || classification.kind === 'forbidden') { setInlineError( classification.kind === 'forbidden' - ? t('prReview.merge.forbidden') + ? mergeForbiddenCopy(providerPlatform, t) : t('prReview.merge.cannotMerge') ); setInlineErrorKind('non-retryable'); @@ -225,7 +351,7 @@ export function PrMergeSheet(props: PrMergeSheetProps) { setInlineErrorKind('retryable'); } } - }, [lastError, t]); + }, [lastError, t, providerPlatform]); useEffect(() => { const sub = Keyboard.addListener('keyboardDidShow', () => { @@ -242,7 +368,29 @@ export function PrMergeSheet(props: PrMergeSheetProps) { setMethod(next); } - function buildMergeInput(): MergePullRequestInput { + function buildMergeInput(): MergeVars { + if (prRef) { + // The provider merge carries the head fence (the server refuses a moved + // head before any merge call); GitLab folds the method into `squash` + // and takes a commit title, Bitbucket only the message — and the form + // hides the title input on that arm (s6f), so no typed value is ever + // dropped. The term rides the fingerprint so a retried intent re-merges + // the same revision. + const commitMessage = messageRef.current.trim(); + return { + expectedHeadSha: headSha, + ...(prRef.platform === 'gitlab' + ? { + squash: method === 'squash', + ...(titleRef.current.trim().length > 0 + ? { commitTitle: titleRef.current.trim() } + : {}), + } + : {}), + deleteBranch: showDeleteBranchToggle ? deleteBranch : false, + ...(commitMessage.length > 0 ? { commitMessage } : {}), + }; + } return { owner, repo: repoName, @@ -255,7 +403,13 @@ export function PrMergeSheet(props: PrMergeSheetProps) { }; } - function buildAutoMergeInput(): AutoMergeInput { + function buildAutoMergeInput(): EnableAutoMergeVars { + if (prRef) { + // GitLab arms merge-when-pipeline-succeeds fenced on the head; the + // method rides the server-side squash handling. Bitbucket never gets + // here: the capability banner replaces the form. + return { expectedHeadSha: headSha }; + } const autoMethod: 'MERGE' | 'SQUASH' | 'REBASE' = (() => { if (method === 'merge') { return 'MERGE'; @@ -328,8 +482,16 @@ export function PrMergeSheet(props: PrMergeSheetProps) { void performSubmit(); }; + // Provider arms word the nouns after the connected provider (merge + // request vs pull request); GitHub keeps its exact pre-s6 copy. if (mode === 'merge') { - Alert.alert(t('prReview.merge.confirmTitle'), t('prReview.merge.confirmMessage'), [ + const [confirmTitle, confirmMessage] = prRef + ? [ + t('prReview.merge.confirmTitleTerm', { term: t(providerPrNounKey(prRef.platform)) }), + t('prReview.merge.confirmMessage'), + ] + : [t('prReview.merge.confirmTitle'), t('prReview.merge.confirmMessage')]; + Alert.alert(confirmTitle, confirmMessage, [ { text: t('common.cancel'), style: 'cancel' }, { text: t('prReview.merge.merge'), style: 'destructive', onPress: submit }, ]); @@ -337,7 +499,11 @@ export function PrMergeSheet(props: PrMergeSheetProps) { } Alert.alert( t('prReview.merge.enableAutoMergeConfirmTitle'), - t('prReview.merge.enableAutoMergeConfirmMessage'), + prRef + ? t('prReview.merge.enableAutoMergeConfirmMessageTerm', { + term: t(providerPrNounKey(prRef.platform)), + }) + : t('prReview.merge.enableAutoMergeConfirmMessage'), [ { text: t('common.cancel'), style: 'cancel' }, { text: t('prReview.merge.enableAutoMerge'), style: 'destructive', onPress: submit }, @@ -347,6 +513,12 @@ export function PrMergeSheet(props: PrMergeSheetProps) { const submitLabel = mode === 'merge' ? t('prReview.merge.merge') : t('prReview.merge.enableAutoMerge'); + // Provider arms (s6): the provider's own noun for the confirm copy, and the + // two auto-merge shapes — GitLab arms through the seam, Bitbucket Cloud has + // no auto-merge API and opens onto the capability banner instead. + const providerTerm = prRef ? t(providerPrNounKey(prRef.platform)) : ''; + const providerAutoMerge = Boolean(prRef) && mode === 'enable-auto-merge'; + const autoMergeUnsupported = providerAutoMerge && autoMergeCapability?.supported === false; // A repository can (rarely) have every merge method disabled. GitHub would // reject any submission, so surface it explicitly and block the action // rather than sending a method the repo does not allow. @@ -361,14 +533,126 @@ export function PrMergeSheet(props: PrMergeSheetProps) { onDismiss(); } + // The body a settled draft renders: the arm the provider state selects — + // the Bitbucket auto-merge capability banner, the GitLab auto-merge body, + // a blocked merge state's restrictions, or the form. The cancel-only + // arms share the ghost footer button. + function cancelOnlyFooter() { + return ( + + + + ); + } + + const settledBody = ((): ReactNode => { + if (autoMergeUnsupported) { + // A Bitbucket auto-merge opens onto the capability banner: the provider + // has no API to arm, so there is nothing to submit or retry. + return ( + <> + + + + {bodySpacer()} + {cancelOnlyFooter()} + + ); + } + if (providerAutoMerge) { + return ( + <> + + {bodySpacer()} + + + + + + ); + } + if (mergeState && !mergeState.canMerge) { + // A blocked merge state replaces the form with the restrictions list + // (nothing to submit). + return ( + <> + + + + {bodySpacer()} + {cancelOnlyFooter()} + + ); + } + return ( + <> + {mergeState ? ( + + + + ) : null} + + + ); + })(); + // PickerSheet invariant: [header, ScrollView]; footer is trailing content. + // Provider arms (s6): the s2/s3 merge state renders as the restrictions + // list; a blocked state replaces the form with that list (nothing to + // submit), and a Bitbucket auto-merge opens onto the capability banner. return ( <> - {draft.settled ? ( - - ) : null} + {draft.settled ? settledBody : null} ); } + +/** The localized copy for one provider blocked reason; `other` keeps the server's message. */ +function providerBlockedReasonText( + reason: ProviderPrMergeBlockedReason, + term: string, + t: ReturnType['t'] +): string { + // Literal keys, never a template: the catalog check scans the source for + // the keys a lookup passes on, and a computed key is invisible to it. + const KEY_BY_CODE = { + conflicts: 'prReview.merge.blocked.conflictsDetail', + required_approvals: 'prReview.merge.blocked.requiredReviewsDetail', + failing_pipeline: 'prReview.merge.providerBlocked.failingPipeline', + pending_pipeline: 'prReview.merge.providerBlocked.pendingPipeline', + draft: 'prReview.merge.providerBlocked.draft', + permission: 'prReview.merge.providerBlocked.permission', + other: null, + } satisfies Record; + const key = KEY_BY_CODE[reason.code]; + if (key === null) { + return reason.message; + } + return t(key, { term }); +} + +/** + * The s2/s3 merge state as an explicit restrictions list (s6): the branch + * policy flags first, then the provider's concrete blocked reasons. Rows the + * reasons list already carries are not repeated from the flags. + */ +export function MergeRestrictionsList({ + mergeState, + term, +}: Readonly<{ mergeState: ProviderPrMergeState; term: string }>) { + const { t } = useTranslation(); + const hasConflictReason = mergeState.blockedReasons.some(reason => reason.code === 'conflicts'); + const hasApprovalsReason = mergeState.blockedReasons.some( + reason => reason.code === 'required_approvals' + ); + const hasPipelineReason = mergeState.blockedReasons.some( + reason => reason.code === 'failing_pipeline' || reason.code === 'pending_pipeline' + ); + const rows: { id: string; text: string }[] = []; + if (mergeState.conflicts && !hasConflictReason) { + rows.push({ id: 'conflicts', text: t('prReview.merge.blocked.conflictsDetail') }); + } + if (mergeState.approvalsRequired > 0 && !hasApprovalsReason) { + rows.push({ + id: 'approvals', + text: t('prReview.merge.restrictions.approvalsRequired', { + count: mergeState.approvalsRequired, + displayCount: formatNumber(mergeState.approvalsRequired, i18n.language), + }), + }); + } + if (mergeState.pipelineMustSucceed && !hasPipelineReason) { + rows.push({ + id: 'pipeline', + text: t('prReview.merge.restrictions.pipelineMustSucceed'), + }); + } + for (const reason of mergeState.blockedReasons) { + rows.push({ + id: `blocked:${reason.code}:${reason.message}`, + text: providerBlockedReasonText(reason, term, t), + }); + } + if (rows.length === 0) { + return null; + } + return ( + + + {t('prReview.merge.restrictions.title')} + + {rows.map(row => ( + + {'• '} + {row.text} + + ))} + + ); +} + +/** + * The GitLab auto-merge body (s6): the merge state's restrictions plus one + * plain explanation. No form fields exist on this arm — arming rides only + * the head fence. + */ +export function ProviderAutoMergeBody({ + mergeState, + term, +}: Readonly<{ mergeState: ProviderPrMergeState | null | undefined; term: string }>) { + const { t } = useTranslation(); + return ( + + {mergeState ? : null} + + {t('prReview.merge.enableAutoMergeDescriptionTerm', { term })} + + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-form-sheet-chrome.tsx b/apps/mobile/src/components/pr-review/pr-form-sheet-chrome.tsx index 15e35b7283..2c93c9d06d 100644 --- a/apps/mobile/src/components/pr-review/pr-form-sheet-chrome.tsx +++ b/apps/mobile/src/components/pr-review/pr-form-sheet-chrome.tsx @@ -49,6 +49,9 @@ export function useFormSheetKeyboardVisible(): boolean { export function PrFormSheetHeader(props: { title: string; eyebrow: string; onBack: () => void }) { return ( + {/* Left-aligned heading on the back row: `centerTitle` would split the + header into a centered title row and a second row holding a lone + dismiss chevron, which read as a stray control under the title. */} { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => { + const i18n = actual.getI18n(); + return { t: i18n.t.bind(i18n), i18n }; + }, + }; +}); + +vi.mock('react-native', () => ({ View: 'View' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); + +const UNSUPPORTED: ProviderReviewCapability = { + supported: false, + reason: 'Bitbucket Cloud does not expose auto-merge in its API', +}; +const SUPPORTED: ProviderReviewCapability = { supported: true, reason: '' }; + +function renderBanner( + capability: ProviderReviewCapability | undefined +): TestRenderer.ReactTestRenderer { + let renderer: TestRenderer.ReactTestRenderer | null = null; + act(() => { + renderer = TestRenderer.create(createElement(PrReviewCapabilityBanner, { capability })); + }); + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- the act() callback runs synchronously; this narrows the definite assignment + if (!renderer) { + throw new Error('Failed to create test renderer'); + } + return renderer; +} + +function textsOf(renderer: TestRenderer.ReactTestRenderer): string[] { + return renderer.root + .findAll(node => (node.type as string) === 'Text') + .flatMap(node => [node.props.children as string]) + .flat() + .filter((child): child is string => typeof child === 'string'); +} + +describe('PrReviewCapabilityBanner', () => { + it('renders the localized title and the provider reason for a supported:false capability', () => { + const renderer = renderBanner(UNSUPPORTED); + const texts = textsOf(renderer); + expect(texts).toContain('Not available on this provider'); + expect(texts).toContain(UNSUPPORTED.reason); + renderer.unmount(); + }); + + it('announces title and reason together for accessibility', () => { + const renderer = renderBanner(UNSUPPORTED); + const view = renderer.root.find(node => (node.type as string) === 'View'); + expect(view.props.accessibilityLabel).toBe( + `Not available on this provider: ${UNSUPPORTED.reason}` + ); + renderer.unmount(); + }); + + it('renders nothing for a supported capability — the affordance itself shows', () => { + const renderer = renderBanner(SUPPORTED); + expect(renderer.toJSON()).toBeNull(); + renderer.unmount(); + }); + + it('renders nothing while the capability is not loaded (undefined)', () => { + const renderer = renderBanner(undefined); + expect(renderer.toJSON()).toBeNull(); + renderer.unmount(); + }); +}); diff --git a/apps/mobile/src/components/pr-review/pr-review-capability-banner.tsx b/apps/mobile/src/components/pr-review/pr-review-capability-banner.tsx new file mode 100644 index 0000000000..1dcda5690c --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-capability-banner.tsx @@ -0,0 +1,37 @@ +// The explicit capability explanation (s6). A provider that cannot do +// something answers `{ supported: false, reason }`; this banner renders that +// answer as a visible, localized explanation — never a silent absence and +// never a generic failure. Any review surface holding a capability object +// (the merge sheet's Bitbucket auto-merge arm today, the discussion +// limitations after it) renders it through here so the wording stays one. + +import { useTranslation } from 'react-i18next'; +import { View } from 'react-native'; + +import { type ProviderReviewCapability } from '@kilocode/app-shared/provider-review'; + +import { Text } from '@/components/ui/text'; + +export function PrReviewCapabilityBanner({ + capability, +}: Readonly<{ capability: ProviderReviewCapability | undefined }>) { + const { t } = useTranslation(); + // A supported (or not-yet-loaded) capability has nothing to explain: the + // surface renders the affordance itself, so the banner draws nothing. + if (capability === undefined || capability.supported) { + return null; + } + return ( + + + {t('prReview.capabilities.banner.title')} + + {capability.reason} + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-checks-section.mounted.test.tsx b/apps/mobile/src/components/pr-review/pr-review-checks-section.mounted.test.tsx index 3c2fac4fdb..cae7521cd3 100644 --- a/apps/mobile/src/components/pr-review/pr-review-checks-section.mounted.test.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-checks-section.mounted.test.tsx @@ -1,9 +1,14 @@ /* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the repository's native-free mounted test tool. */ +// eslint-disable-next-line import/no-nodejs-modules -- vitest-only theme-token guard, runs in node, never bundled into the app +import { readFileSync } from 'node:fs'; + import { createElement } from 'react'; import TestRenderer, { act } from 'react-test-renderer'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SpinningIcon } from '@/components/ui/spinning-icon'; +import { ProviderPrScopeProvider } from '@/lib/pr-review/provider-pr-ref'; +import { openExternalUrl } from '@/lib/external-link'; import { PrReviewChecksSection } from './pr-review-checks-section'; const query = vi.hoisted(() => ({ @@ -60,6 +65,7 @@ vi.mock('@/components/ui/icons', () => ({ XCircle: 'XCircle', })); vi.mock('@/components/ui/spinning-icon', () => ({ SpinningIcon: 'SpinningIcon' })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); vi.mock('@/components/pr-review/pr-review-reconnect-notice', () => ({ PrReviewReconnectNotice: 'PrReviewReconnectNotice', @@ -83,7 +89,10 @@ vi.mock('@/lib/pr-review/classify-pr-review-query-state', () => ({ classifyPrReviewQueryState: () => ({ kind: 'retryable' }), })); vi.mock('@/lib/trpc', () => ({ - useTRPC: () => ({ githubPrReview: { listChecks: { queryOptions: () => ({}) } } }), + useTRPC: () => ({ + githubPrReview: { listChecks: { queryOptions: () => ({}) } }, + providerReview: { listChecks: { queryOptions: () => ({}) } }, + }), })); describe('PrReviewChecksSection check status icons', () => { @@ -123,3 +132,211 @@ describe('PrReviewChecksSection check status icons', () => { }); }); }); + +describe('PrReviewChecksSection view-on-provider link', () => { + const gitlabScope = { + ref: { + platform: 'gitlab' as const, + projectPath: 'group/sub/repo', + mrIid: 12, + instanceHint: 'https://gitlab.example.com', + }, + organizationId: null, + }; + + function mountSection(scope?: { ref: typeof gitlabScope.ref; organizationId: string | null }) { + const previousData = query.data; + query.data = { + checkRuns: [], + rollup: { total: 0, success: 0, failure: 0, pending: 0, skipped: 0 }, + }; + const section = ( + + ); + const renderer: { current: TestRenderer.ReactTestRenderer | undefined } = { + current: undefined, + }; + act(() => { + renderer.current = TestRenderer.create( + scope ? {section} : section + ); + }); + const created = renderer.current; + if (!created) { + throw new Error('renderer was not created'); + } + return { + renderer: created, + restore: () => { + query.data = previousData; + }, + }; + } + + beforeEach(() => { + vi.mocked(openExternalUrl).mockClear(); + }); + + function pressViewButton(renderer: TestRenderer.ReactTestRenderer) { + const button = renderer.root.findAllByType('Button' as never)[0]; + if (!button) { + throw new Error('the view-on-provider button did not render'); + } + act(() => { + (button.props.onPress as () => void)(); + }); + } + + it('labels the opened link "pull request" on the GitHub arm', () => { + const { renderer, restore } = mountSection(); + pressViewButton(renderer); + + expect(openExternalUrl).toHaveBeenCalledWith('https://github.com/group/sub/repo/pull/12', { + label: 'prReview.terms.pullRequest', + }); + act(() => { + renderer.unmount(); + }); + restore(); + }); + + it('labels the same link "merge request" on a GitLab scope', () => { + const { renderer, restore } = mountSection(gitlabScope); + pressViewButton(renderer); + + expect(openExternalUrl).toHaveBeenCalledWith( + 'https://gitlab.example.com/group/sub/repo/-/merge_requests/12', + { label: 'prReview.terms.mergeRequest' } + ); + act(() => { + renderer.unmount(); + }); + restore(); + }); +}); + +// Spot check e1-nav-mr.png: the CHECKS section rendered as an empty gray +// block — no loading indicator, no empty copy, no error copy. The screen +// was in the loading state, and the state was invisible: the skeleton bars +// carried `bg-muted` inside a `bg-secondary` card, and `--muted` equals +// `--secondary` in BOTH themes (apps/mobile/src/global.css), so the bars +// painted the card's own colour. This test pins the fixed render path: the +// card holds three shared Skeleton bars in `bg-muted-soft` — the one gray +// that differs from the card in both themes — and no bar keeps the +// collision token. The CSS guard below proves the collision is real +// (`--muted` == `--secondary`) and that the token the bars now use is not +// the collision token, so a future theme change that reintroduces the +// collision fails here instead of shipping another empty gray block. +describe('PrReviewChecksSection loading state is visible on the card', () => { + const previous = { isLoading: false, data: query.data }; + + beforeEach(() => { + query.isLoading = true; + }); + + afterEach(() => { + query.isLoading = previous.isLoading; + query.data = previous.data; + }); + + function mountLoading() { + const renderer: { current: TestRenderer.ReactTestRenderer | undefined } = { + current: undefined, + }; + act(() => { + renderer.current = TestRenderer.create( + createElement(PrReviewChecksSection, { + owner: 'group/sub', + repo: 'repo', + number: 12, + headSha: 'head', + }) + ); + }); + const created = renderer.current; + if (!created) { + throw new Error('renderer was not created'); + } + return created; + } + + function findCard(renderer: TestRenderer.ReactTestRenderer) { + const cards = renderer.root.findAll( + node => + String(node.type) === 'View' && + typeof node.props.className === 'string' && + node.props.className.split(/\s+/).includes('bg-secondary') + ); + expect(cards).toHaveLength(1); + const card = cards[0]; + if (!card) { + throw new Error('the checks card did not render'); + } + return card; + } + + it('paints three animated skeleton bars in a colour distinct from the card', () => { + const renderer = mountLoading(); + const card = findCard(renderer); + + // The shared Skeleton component — the app's loading indicator (pulse + + // shimmer), not a static block. + const bars = card.findAll(node => String(node.type) === 'Skeleton'); + expect(bars).toHaveLength(3); + for (const bar of bars) { + const className = String(bar.props.className ?? ''); + expect(className.split(/\s+/)).toContain('bg-muted-soft'); + } + + // The defect itself: no node in the card may keep the bare `bg-muted` + // token — on this card it is the card's own colour, i.e. invisible. + const invisible = card.findAll( + node => + typeof node.props.className === 'string' && + /(^|\s)bg-muted(\s|$)/.test(node.props.className) + ); + expect(invisible).toHaveLength(0); + + // The state is announced, not only shown. + expect(card.props.accessibilityRole).toBe('progressbar'); + expect(card.props.accessibilityLabel).toBe('common.loading'); + + act(() => { + renderer.unmount(); + }); + }); + + it('keeps the CHECKS heading rendered while loading, so the block is labelled', () => { + const renderer = mountLoading(); + const headings = renderer.root.findAll( + node => String(node.type) === 'Text' && node.props.children === 'prReview.checks.title' + ); + expect(headings).toHaveLength(1); + act(() => { + renderer.unmount(); + }); + }); + + it('guards the theme tokens: bg-muted collides with the card, bg-muted-soft does not', () => { + const css = readFileSync(new URL('../../global.css', import.meta.url), 'utf8'); + const values = (name: string) => + [...css.matchAll(new RegExp(`--${name}:\\s*([^;]+);`, 'g'))].map(match => + (match[1] ?? '').trim().toLowerCase() + ); + const secondary = values('secondary'); + const muted = values('muted'); + const mutedSoft = values('muted-soft'); + + // Both theme blocks are present. + expect(secondary.length).toBeGreaterThanOrEqual(2); + expect(muted).toHaveLength(secondary.length); + expect(mutedSoft).toHaveLength(secondary.length); + // The collision that made the old skeleton invisible, pinned so the + // test above stays meaningful. + expect(muted).toEqual(secondary); + // The fix token must contrast with the card in every theme. + for (const [index, soft] of mutedSoft.entries()) { + expect(soft).not.toBe(secondary[index]); + } + }); +}); diff --git a/apps/mobile/src/components/pr-review/pr-review-checks-section.test.tsx b/apps/mobile/src/components/pr-review/pr-review-checks-section.test.tsx index 810930ca99..7e3377f252 100644 --- a/apps/mobile/src/components/pr-review/pr-review-checks-section.test.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-checks-section.test.tsx @@ -61,6 +61,9 @@ vi.mock('@/components/ui/icons', () => ({ })); vi.mock('@/components/ui/spinning-icon', () => ({ SpinningIcon: 'SpinningIcon' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +// The section's loading card renders the UI skeleton; the real one reaches +// expo-linear-gradient and the reanimated worklets, which stay unmocked here. +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); vi.mock('@/i18n', () => ({ i18n: { language: 'en', t: (key: string) => key } })); vi.mock('@/lib/external-link', () => ({ openExternalUrl: vi.fn() })); vi.mock('@/lib/format', () => ({ diff --git a/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx b/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx index 7b3f5bce5f..9c0b1ef608 100644 --- a/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- the section owns every CHECKS state in one file: the card shell, the tone/rollup helpers and the rows share one surface, and splitting the visible-loading fix away from the states it must match scatters it across callers. */ import { useQuery } from '@tanstack/react-query'; import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; import { @@ -9,19 +10,21 @@ import { MinusCircle, XCircle, } from '@/components/ui/icons'; -import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, View } from 'react-native'; import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice'; import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; import { SpinningIcon } from '@/components/ui/spinning-icon'; import { Text } from '@/components/ui/text'; import { i18n } from '@/i18n'; +import { reviewerPlatformLabel } from '@/lib/code-reviewer-config'; import { formatList, formatNumber } from '@/lib/format'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { classifyPrReviewQueryState } from '@/lib/pr-review/classify-pr-review-query-state'; -import { useTRPC } from '@/lib/trpc'; +import { useProviderPrQueries } from '@/lib/pr-review/provider-pr-queries'; +import { providerPrTermKey, providerPrWebUrl } from '@/lib/pr-review/provider-pr-ref'; import { cn } from '@/lib/utils'; import { openExternalUrl } from '@/lib/external-link'; @@ -175,30 +178,36 @@ export function PrReviewChecksSection({ number, headSha, }: PrReviewChecksSectionProps) { - const trpc = useTRPC(); + const queries = useProviderPrQueries({ owner, repo, number }); const colors = useThemeColors(); const { t } = useTranslation(); - const prUrl = useMemo( - () => `https://github.com/${owner}/${repo}/pull/${number}`, - [owner, repo, number] - ); + // Null on a GitLab ref with no instance hint: no host, so no link out. + const prUrl = providerPrWebUrl(queries.ref); - const checks = useQuery( - trpc.githubPrReview.listChecks.queryOptions({ owner, repo, ref: headSha }) - ); + const checks = useQuery(queries.checksOptions(headSha)); // Loading (first time, no cached data): show three skeleton rows in a // card so the section matches the final dimensions once the data lands. + // The bars must NOT be `bg-muted` here: `--muted` and `--secondary` are + // the same colour in both themes (apps/mobile/src/global.css), so a + // `bg-muted` bar inside this `bg-secondary` card paints nothing and the + // section reads as an empty gray block (spot check e1-nav-mr). The shared + // Skeleton gives the pulse + shimmer, and `bg-muted-soft` is the one gray + // that contrasts with the card in both themes. if (checks.isLoading) { return ( {t('prReview.checks.title')} - - - - + + + + ); @@ -269,6 +278,11 @@ export function PrReviewChecksSection({ ); } + const viewOnProviderLabel = + queries.platform === 'github' + ? t('prReview.checks.viewOnGitHub') + : t('prReview.terms.viewOnProvider', { provider: reviewerPlatformLabel(queries.platform) }); + const data = checks.data; const runList = data?.checkRuns ?? []; const rollup = data?.rollup ?? { total: 0, success: 0, failure: 0, pending: 0, skipped: 0 }; @@ -282,18 +296,20 @@ export function PrReviewChecksSection({ {rollupLine} - + {prUrl ? ( + + ) : null} ); diff --git a/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx index 26541d6c69..9848fb2468 100644 --- a/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { type ReactNode, useEffect, useRef } from 'react'; +import { type ReactNode, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert } from 'react-native'; import { ActivityIndicator } from '@/components/ui/activity-indicator'; @@ -14,6 +14,14 @@ import { InvalidRouteState } from '@/components/invalid-route-state'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { parseComposerParams } from '@/lib/pr-review/comment-composer-params'; import { usePendingReview } from '@/lib/pr-review/pending-review-provider'; +import { buildPrOverviewQueryOptions } from '@/lib/pr-review/provider-pr-queries'; +import { + isProviderScopeReady, + parseProviderPrRoute, + providerPrRefLabel, + providerPrTriple, + useProviderPrScope, +} from '@/lib/pr-review/provider-pr-ref'; import { useTRPC } from '@/lib/trpc'; type Params = { @@ -25,30 +33,76 @@ type Params = { line: string; startLine?: string; pendingId?: string; + // Provider route shape (`[platform]/[...identity]/comment-composer`). + platform?: string; + identity?: string[] | string; + instance?: string; }; +/** + * Comment-composer formSheet, mounted by BOTH routes: the GitHub + * `[owner]/[repo]/[number]/comment-composer` route and the provider + * `[platform]/[...identity]/comment-composer` route (s6). The route decides + * the ref; the provider layout publishes the scope the queries run under. + */ export function PrReviewCommentComposerScreen() { const router = useRouter(); const colors = useThemeColors(); const { t } = useTranslation(); const params = useLocalSearchParams(); - const parsed = parseComposerParams(params); const pending = usePendingReview(); + // The provider route carries the identity segments; the GitHub route the + // plain triple. Exactly one parses — the provider layout redirects a + // hand-built `/pr-review/github/...` link to the GitHub route. + const providerRef = useMemo( + () => + parseProviderPrRoute({ + platform: params.platform, + identity: params.identity, + instance: params.instance, + }), + [params.platform, params.identity, params.instance] + ); + const providerTriple = providerRef ? providerPrTriple(providerRef) : null; + const parsed = useMemo( + () => + parseComposerParams( + providerTriple + ? { + ...params, + owner: providerTriple.owner, + repo: providerTriple.repo, + number: String(providerTriple.number), + } + : params + ), + // `providerTriple` is derived from `providerRef`; the segments it reads + // are the same memo inputs. + [params, providerTriple] + ); + const pendingId = parsed?.pendingId; const isEdit = pendingId !== undefined; const pendingItem = isEdit ? pending.items.find(item => item.id === pendingId) : undefined; const title = isEdit ? t('prReview.composer.editTitle') : t('prReview.composer.addTitle'); - const eyebrow = parsed ? `${parsed.owner}/${parsed.repo}#${parsed.number}` : ''; + const githubEyebrow = parsed ? `${parsed.owner}/${parsed.repo}#${parsed.number}` : ''; + const eyebrow = providerRef ? providerPrRefLabel(providerRef) : githubEyebrow; + + // The scope the overview runs under: the layout publishes the provider + // scope in context; the GitHub route falls back to the parsed triple, so + // the GitHub query key is byte-identical to the pre-s6 one. + const scope = useProviderPrScope(parsed ?? { owner: '', repo: '', number: 0 }); // Edit mode is local-only: do not fire getPullRequest and do not gate on it. + // The readiness gate rides the same predicate the provider reads use, so a + // Bitbucket scope without its organization waits instead of querying. const trpc = useTRPC(); - const pr = useQuery( - trpc.githubPrReview.getPullRequest.queryOptions( - { owner: parsed?.owner ?? '', repo: parsed?.repo ?? '', number: parsed?.number ?? 0 }, - { enabled: parsed !== null && !isEdit } - ) - ); + const overviewOptions = useMemo(() => buildPrOverviewQueryOptions(trpc, scope), [trpc, scope]); + const pr = useQuery({ + ...overviewOptions, + enabled: parsed !== null && !isEdit && isProviderScopeReady(scope), + }); // Missing pending item: alert above the formSheet and back out once. const missingAlertedRef = useRef(false); @@ -83,6 +137,7 @@ export function PrReviewCommentComposerScreen() { owner={parsed.owner} repo={parsed.repo} number={parsed.number} + prRef={providerRef && providerRef.platform !== 'github' ? providerRef : undefined} mode={{ kind: 'edit', pendingItemId: pendingItem.id }} path={parsed.path} side={parsed.side} @@ -104,6 +159,7 @@ export function PrReviewCommentComposerScreen() { owner={parsed.owner} repo={parsed.repo} number={parsed.number} + prRef={providerRef && providerRef.platform !== 'github' ? providerRef : undefined} mode={{ kind: 'create', headSha: pr.data.headSha }} path={parsed.path} side={parsed.side} @@ -116,10 +172,17 @@ export function PrReviewCommentComposerScreen() { ); } - let body: ReactNode = null; + // A route with no valid comment target is not a broken comment flow: the + // route failed, nothing the composer could post. The "Add comment" chrome + // over a "Page not found" body read as a comment sheet that cannot save, so + // the terminal invalid state renders alone — no misleading title, no lone + // dismiss chevron — and carries its own Go back to the shared inbox. if (!parsed) { - body = ; - } else if (isEdit) { + return ; + } + + let body: ReactNode = null; + if (isEdit) { body = null; } else if (pr.isLoading) { body = ( diff --git a/apps/mobile/src/components/pr-review/pr-review-comment-composer.test.tsx b/apps/mobile/src/components/pr-review/pr-review-comment-composer.test.tsx index 1a1820b71d..f79cb0ca9c 100644 --- a/apps/mobile/src/components/pr-review/pr-review-comment-composer.test.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-comment-composer.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- the composer suite covers the draft clear rules and the provider-arm anchored post in one cohesive file */ // Clear-rule coverage for the comment composer's durable draft. The composer // clears its draft on three committed outcomes — comment post, add-to-review, // and a confirmed discard — and keeps it on a dismissed-without-confirmation @@ -14,6 +15,7 @@ import '@/i18n'; import type * as ReactI18next from 'react-i18next'; import { PrReviewCommentComposer } from './pr-review-comment-composer'; import { clearDraft } from '@/lib/persist/drafts'; +import { providerPrRefKey } from '@/lib/pr-review/provider-pr-ref'; vi.mock('react-i18next', async importOriginal => { const actual = await importOriginal(); @@ -256,6 +258,18 @@ describe('PrReviewCommentComposer draft clear rules', () => { footerProp(element, 'onCommentNow')?.(); await flushMicrotasks(); + // The GitHub arm keeps its exact pre-s6 variables: the position rides + // the flat input fields, never the provider `anchor` shape (c3). + expect(createCommentMocks.mutateAsync).toHaveBeenCalledWith({ + owner: 'octocat', + repo: 'hello', + number: 1, + body: 'hello', + path: 'src/a.ts', + line: 10, + side: 'RIGHT', + commitSha: 'a'.repeat(40), + }); expect(clearDraft).toHaveBeenCalledWith('u1', 'pr-comment:key'); }); @@ -287,3 +301,73 @@ describe('PrReviewCommentComposer draft clear rules', () => { expect(clearDraft).not.toHaveBeenCalled(); }); }); + +describe('PrReviewCommentComposer provider arm (s6)', () => { + // A GitLab MR with the same owner/repo/number triple as the GitHub + // fixtures: the folded draft key and the anchored post must differ from + // the GitHub arm in both bytes. + const gitlabRef = { platform: 'gitlab' as const, projectPath: 'octocat/hello', mrIid: 1 }; + const providerProps = { ...baseProps, prRef: gitlabRef }; + + function mountProviderComposer(): React.ReactElement { + // eslint-disable-next-line new-cap + return PrReviewCommentComposer(providerProps); + } + + beforeEach(() => { + createCommentMocks.mutateAsync.mockReset(); + createCommentMocks.isPending = false; + createCommentMocks.error = null; + vi.clearAllMocks(); + }); + + it('posts the tapped diff position as the real anchor through the provider arm (c3)', async () => { + createCommentMocks.mutateAsync.mockResolvedValueOnce({}); + const element = mountProviderComposer(); + typeBody(element, 'hello'); + footerProp(element, 'onCommentNow')?.(); + await flushMicrotasks(); + + expect(createCommentMocks.mutateAsync).toHaveBeenCalledWith({ + body: 'hello', + anchor: { path: 'src/a.ts', side: 'RIGHT', line: 10 }, + }); + }); + + it('carries a multi-line range into the anchor (c3)', async () => { + createCommentMocks.mutateAsync.mockResolvedValueOnce({}); + // eslint-disable-next-line new-cap + const element = PrReviewCommentComposer({ + ...providerProps, + startLine: 8, + }); + typeBody(element, 'hello'); + footerProp(element, 'onCommentNow')?.(); + await flushMicrotasks(); + + expect(createCommentMocks.mutateAsync).toHaveBeenCalledWith({ + body: 'hello', + anchor: { path: 'src/a.ts', side: 'RIGHT', line: 10, startLine: 8 }, + }); + }); + + it('keeps the provider comment out of the pending queue path (comment now is direct)', () => { + createCommentMocks.mutateAsync.mockResolvedValueOnce({}); + const element = mountProviderComposer(); + typeBody(element, 'hello'); + footerProp(element, 'onAddToReview')?.(); + + // Add-to-review stays provider-agnostic: the queue is local and the + // submit sheet folds it into the review body later. + expect(createCommentMocks.mutateAsync).not.toHaveBeenCalled(); + }); + + it('folds the provider ref identity into the durable comment draft key', () => { + const element = mountProviderComposer(); + typeBody(element, 'hello'); + footerProp(element, 'onAddToReview')?.(); + + const expected = `pr-comment:key@${providerPrRefKey(gitlabRef)}`; + expect(clearDraft).toHaveBeenCalledWith('u1', expected); + }); +}); diff --git a/apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx b/apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx index 9b37ca9e58..1371ce4a39 100644 --- a/apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx @@ -36,6 +36,7 @@ import { buildSuggestionFence } from '@/lib/pr-review/build-suggestion-fence'; import { getDiffSelection } from '@/lib/pr-review/diff-selection-bridge'; import { usePendingReview } from '@/lib/pr-review/pending-review-provider'; import { useCreateReviewCommentMutation } from '@/lib/pr-review/use-pr-review-mutations'; +import { type ProviderPrRef, providerPrRefKey } from '@/lib/pr-review/provider-pr-ref'; type CommentComposerMode = | { kind: 'create'; headSha: string } @@ -45,6 +46,14 @@ type PrReviewCommentComposerProps = Readonly<{ owner: string; repo: string; number: number; + /** + * The provider ref (s6). Present on the GitLab/Bitbucket surface: the + * comment posts through `providerReview.addComment` with the tapped diff + * position as a real anchor (c3), and the durable comment draft key folds + * the ref identity so a same-numbered GitHub PR never shares this sheet's + * draft. Absent on GitHub, which keeps the exact pre-s6 write path. + */ + prRef?: ProviderPrRef; mode: CommentComposerMode; path: string; side: 'LEFT' | 'RIGHT'; @@ -62,6 +71,7 @@ export function PrReviewCommentComposer(props: PrReviewCommentComposerProps) { owner, repo, number, + prRef, mode, path, side, @@ -74,13 +84,18 @@ export function PrReviewCommentComposer(props: PrReviewCommentComposerProps) { } = props; const pending = usePendingReview(); const { t } = useTranslation(); - const createComment = useCreateReviewCommentMutation({ owner, repo, number }); + const createComment = useCreateReviewCommentMutation(prRef ?? { owner, repo, number }); const isEdit = mode.kind === 'edit'; // Durable comment draft (create mode only). Edit mode edits an already-queued // item, durable through the pending-review provider, so no draft there. const { userId, isLoading: isIdentityLoading } = useCurrentUserId(); - const commentDraftKey = prCommentDraftKey(owner, repo, number, path, side, line, startLine); + const positionDraftKey = prCommentDraftKey(owner, repo, number, path, side, line, startLine); + // Provider arms fold the collision-free ref identity into the key (identity + // rule 17); the GitHub bytes stay exactly as stored before this slice. + const commentDraftKey = prRef + ? `${positionDraftKey}@${providerPrRefKey(prRef)}` + : positionDraftKey; const draftUserId = isEdit ? undefined : userId; const draft = useFencedDraftLoad({ userId: draftUserId, @@ -199,17 +214,32 @@ export function PrReviewCommentComposer(props: PrReviewCommentComposerProps) { return; } try { - await createComment.mutateAsync({ - owner, - repo, - number, - body, - path, - line, - side, - ...(startLine !== undefined ? { startLine, startSide: side } : {}), - commitSha: mode.headSha, - }); + // Provider arms post the real diff position (c3): the sheet's + // path/side/line selection rides the `anchor` the provider router + // turns into an inline discussion / inline comment. + await createComment.mutateAsync( + prRef + ? { + body, + anchor: { + path, + side, + line, + ...(startLine !== undefined ? { startLine } : {}), + }, + } + : { + owner, + repo, + number, + body, + path, + line, + side, + ...(startLine !== undefined ? { startLine, startSide: side } : {}), + commitSha: mode.headSha, + } + ); if (draftUserId) { void clearDraft(draftUserId, commentDraftKey); } diff --git a/apps/mobile/src/components/pr-review/pr-review-connect-gate-view.test.ts b/apps/mobile/src/components/pr-review/pr-review-connect-gate-view.test.ts index c18825f1af..b3aa79454d 100644 --- a/apps/mobile/src/components/pr-review/pr-review-connect-gate-view.test.ts +++ b/apps/mobile/src/components/pr-review/pr-review-connect-gate-view.test.ts @@ -1,14 +1,9 @@ import * as React from 'react'; import { describe, expect, it, vi } from 'vitest'; -import '@/i18n'; import type * as ReactI18next from 'react-i18next'; +import '@/i18n'; import { PrReviewConnectGate } from './pr-review-connect-gate'; -import { - type PrReviewGateView, - selectPrReviewGateView, - type SelectPrReviewGateViewInput, -} from './pr-review-connect-gate-view'; vi.mock('react-i18next', async importOriginal => { const actual = await importOriginal(); @@ -21,57 +16,6 @@ vi.mock('react-i18next', async importOriginal => { }; }); -const base: SelectPrReviewGateViewInput = { - isError: false, - isLoading: false, - connected: true, - revoked: false, -}; - -function viewFor(patch: Partial): PrReviewGateView { - return selectPrReviewGateView({ ...base, ...patch }); -} - -describe('selectPrReviewGateView', () => { - it('returns loading while the query is loading', () => { - expect(viewFor({ isLoading: true })).toBe('loading'); - }); - - it('returns error when the query failed and is not loading', () => { - expect(viewFor({ isError: true })).toBe('error'); - }); - - it('returns error when both error and loading are true', () => { - expect(selectPrReviewGateView({ ...base, isError: true, isLoading: true })).toBe('error'); - }); - - it('returns connect when not connected and not revoked', () => { - expect(viewFor({ connected: false })).toBe('connect'); - }); - - it('returns reconnect when the connection was revoked', () => { - expect(viewFor({ connected: false, revoked: true })).toBe('reconnect'); - }); - - it('returns children when connected', () => { - expect(viewFor({ connected: true })).toBe('children'); - }); - - it('exposes only one happy view and four non-happy header-bearing views', () => { - const inputs: Partial[] = [ - { isLoading: true }, - { isError: true }, - { connected: false }, - { connected: false, revoked: true }, - { connected: true }, - ]; - const views = inputs.map(patch => viewFor(patch)); - expect(views.filter(view => view === 'children')).toHaveLength(1); - expect(views.filter(view => view !== 'children')).toHaveLength(4); - expect(new Set(views).size).toBe(views.length); - }); -}); - // The gate passes `authorization.isPending` (no data yet) to the view // selector, not `isLoading` (isPending && isFetching). A paused query // (offline/unknown connectivity, empty cache) is pending but not fetching, @@ -80,6 +24,9 @@ describe('selectPrReviewGateView', () => { // a revert to `isLoading` would make the paused query render Connect and // fail the assertions below. // +// The view selector itself lives in `@/lib/pr-review/pr-review-connect-gate-view` +// with its decision table beside it. +// // Rendered as a plain function call (same pattern as pr-review-screen.test.tsx) // with hooks and child components stubbed so the tree walk stays deterministic. @@ -118,6 +65,13 @@ vi.mock('@/lib/trpc', () => ({ }), })); +vi.mock('@/lib/config', () => ({ WEB_BASE_URL: 'https://web.example' })); + +vi.mock('expo-router', () => ({ + // Any non-entry pathname reaches the GitHub arm. + usePathname: () => '/pr-review/github/owner/repo/1', +})); + vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({ mutedForeground: '#6F6A61', primaryForeground: '#FFFFFF' }), })); @@ -164,6 +118,14 @@ function containsType(node: unknown, type: string): boolean { if (element.type === type) { return true; } + // A function component element (the gate dispatches to GitHubConnectGate) + // is walked by calling it: hooks are stubbed, so a plain call renders. + if ( + typeof element.type === 'function' && + containsType((element.type as (props: unknown) => unknown)(element.props), type) + ) { + return true; + } return Object.values(element.props as Record).some(value => containsType(value, type) ); diff --git a/apps/mobile/src/components/pr-review/pr-review-connect-gate-view.ts b/apps/mobile/src/components/pr-review/pr-review-connect-gate-view.ts deleted file mode 100644 index b8f0267f8b..0000000000 --- a/apps/mobile/src/components/pr-review/pr-review-connect-gate-view.ts +++ /dev/null @@ -1,29 +0,0 @@ -export type PrReviewGateView = 'error' | 'loading' | 'connect' | 'reconnect' | 'children'; - -export type SelectPrReviewGateViewInput = { - readonly isError: boolean; - readonly isLoading: boolean; - readonly connected: boolean; - readonly revoked: boolean; -}; - -/** - * Pure view selector for the PR-review connect gate. - * - * The gate is intentionally a simple priority ladder: error → loading → - * not-connected → children. This mirrors the original component's branch - * order and keeps every non-happy outcome in a fixed set of header-bearing - * states. - */ -export function selectPrReviewGateView(args: SelectPrReviewGateViewInput): PrReviewGateView { - if (args.isError) { - return 'error'; - } - if (args.isLoading) { - return 'loading'; - } - if (!args.connected) { - return args.revoked ? 'reconnect' : 'connect'; - } - return 'children'; -} diff --git a/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx b/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx index 530c49ae4e..f0bbabdcb3 100644 --- a/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx @@ -1,9 +1,10 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { PlugZap, ShieldAlert } from '@/components/ui/icons'; -import { type ReactNode, useCallback, useState } from 'react'; +import { PlugZap, RefreshCcw, ShieldAlert } from '@/components/ui/icons'; +import { type ReactNode, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Platform, View } from 'react-native'; import { ActivityIndicator } from '@/components/ui/activity-indicator'; +import { usePathname } from 'expo-router'; import { CenteredState } from '@/components/centered-state'; import { toast } from 'sonner-native'; @@ -13,36 +14,86 @@ import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import { WEB_BASE_URL } from '@/lib/config'; +import { getBitbucketIntegrationUrl, getGitLabIntegrationUrl } from '@/lib/integration-urls'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { useExternalAuthReturn } from '@/lib/external-auth/use-external-auth-return'; import { openAuthorizationAndWaitForReturn } from '@/lib/pr-review/connect-gate-platform'; -import { selectPrReviewGateView } from './pr-review-connect-gate-view'; +import { selectPrReviewGateView } from '@/lib/pr-review/pr-review-connect-gate-view'; +import { type ProviderPrPlatform } from '@/lib/pr-review/provider-pr-ref'; import { useTRPC } from '@/lib/trpc'; +/** + * Cold deep links land straight on a gate state with no navigation history, + * so `ScreenHeader` would render without a back control. The provider-neutral + * inbox is the one exit every PR-review surface shares. + */ +const PR_REVIEW_ENTRY_HREF = '/(app)/pr-review' as const; + type PrReviewConnectGateProps = { readonly children: ReactNode; + /** + * Which provider's connection this mount checks. The GitHub detail route + * and every pre-s7 mount keep the default; the provider layout passes the + * route's platform. + */ + readonly platform?: ProviderPrPlatform; + /** The selected organization for a provider check; null = personal scope. */ + readonly organizationId?: string | null; }; /** - * Wraps every PR-review surface. The user's GitHub identity (separate from - * a per-org GitHub App installation) is required to post review comments - * via the mobile app — without it, every mutation would 401 in the same - * way. The gate is the single place that handles: + * Wraps every PR-review surface, with one arm per provider: * + * - GitHub: the user's GitHub identity (separate from a per-org GitHub App + * installation) is required to post review comments — the existing + * `getUserAuthorization` check and `connectUserAuthorization` CTA. + * - GitLab (personal + org) and Bitbucket (org): the integration status the + * s4 endpoints expose; the CTA opens the web integration page and the + * status refetches when the app returns. + * - Bitbucket personal: Cloud has no personal review scope, so the gate + * shows the org-only explanation with no CTA — nothing a retry could fix. + * + * Each arm handles the same states: * - happy: connected → render children - * - retryable: getUserAuthorization fails → QueryError + Retry - * - empty: not connected / revoked → EmptyState CTA - * - non-retryable: structurally n/a (this is a configuration gate, not a - * transient server failure). + * - retryable: the status check fails → QueryError + Retry + * - empty: not connected → EmptyState CTA into the provider's connect flow + * - non-retryable: Bitbucket personal → org-only explanation, no CTA + * + * The entry screen is provider-neutral — a pasted GitLab or Bitbucket link + * must work for a user with no GitHub connection — so the gate is a + * pass-through there; the provider gates protect each detail route. * - * The CTA calls `githubApps.connectUserAuthorization` and opens the + * The GitHub CTA calls `githubApps.connectUserAuthorization` and opens the * returned URL with the platform-appropriate browser launcher (iOS native * auth session that resolves on sheet close; Android custom tab that - * resolves on app-foreground via AppState). Cancellation on either - * platform simply leaves the gate showing — there's nothing to roll - * back because the auth flow is server-driven. + * resolves on app-foreground via AppState). Cancellation on either platform + * simply leaves the gate showing — there's nothing to roll back because the + * auth flow is server-driven. */ -export function PrReviewConnectGate({ children }: PrReviewConnectGateProps) { +export function PrReviewConnectGate({ + children, + platform = 'github', + organizationId = null, +}: PrReviewConnectGateProps) { + const pathname = usePathname(); + // The entry route (`(app)/pr-review` → pathname `/pr-review`) is the one + // PR-review surface that serves all three providers at once; gating it on + // any single connection would lock out the other two. + if (pathname === '/pr-review') { + return <>{children}; + } + if (platform === 'github') { + return {children}; + } + return ( + + {children} + + ); +} + +function GitHubConnectGate({ children }: Readonly<{ children: ReactNode }>) { const trpc = useTRPC(); const queryClient = useQueryClient(); const colors = useThemeColors(); @@ -99,6 +150,7 @@ export function PrReviewConnectGate({ children }: PrReviewConnectGateProps) { }; const view = selectPrReviewGateView({ + platform: 'github', isError: authorization.isError, // `isPending` (no data yet) rather than `isLoading` (isPending && // isFetching): a paused query (offline/unknown connectivity, empty cache) @@ -108,12 +160,13 @@ export function PrReviewConnectGate({ children }: PrReviewConnectGateProps) { isLoading: authorization.isPending, connected: authorization.data?.connected === true, revoked: authorization.data?.revoked === true, + organizationId: null, }); if (view === 'error') { return ( - + - + @@ -142,7 +195,7 @@ export function PrReviewConnectGate({ children }: PrReviewConnectGateProps) { const revoked = view === 'reconnect'; return ( - + {children}; } + +/** + * GitLab / Bitbucket arm. The status query is the s4 integration status the + * connect flow updates; a Bitbucket personal scope disables it entirely + * because there is nothing to check — the selector renders the terminal + * org-only explanation for that case. + */ +function ProviderConnectGate({ + platform, + organizationId, + children, +}: Readonly<{ + platform: Exclude; + organizationId: string | null; + children: ReactNode; +}>) { + const trpc = useTRPC(); + const colors = useThemeColors(); + const { t } = useTranslation(); + + // The two providers answer with different status shapes, so each arm + // subscribes to its own query and only one is enabled per mount — a + // union of the two queryOptions types is not assignable to one useQuery. + const gitlabOptions = useMemo( + () => + organizationId + ? trpc.organizations.reviewAgent.getGitLabStatus.queryOptions({ organizationId }) + : trpc.personalReviewAgent.getGitLabStatus.queryOptions(), + [trpc, organizationId] + ); + const bitbucketOptions = useMemo( + () => + trpc.organizations.reviewAgent.getBitbucketReadiness.queryOptions({ + organizationId: organizationId ?? '', + }), + [trpc, organizationId] + ); + // Bitbucket Cloud is organization-context only (s4): with no organization + // selected there is no endpoint to ask, so the query stays disabled and the + // gate renders the org-only explanation. + const gitlabStatus = useQuery({ ...gitlabOptions, enabled: platform === 'gitlab' }); + const bitbucketStatus = useQuery({ + ...bitbucketOptions, + enabled: platform === 'bitbucket' && organizationId !== null, + }); + const status = platform === 'gitlab' ? gitlabStatus : bitbucketStatus; + + const refetchStatus = useCallback(() => { + void status.refetch(); + }, [status]); + const { markLaunched, clearLaunch } = useExternalAuthReturn(refetchStatus); + const [connecting, setConnecting] = useState(false); + + const handleConnect = async () => { + setConnecting(true); + try { + // The provider connections are web-side integrations: open the + // existing integration page and re-check the status when the app + // returns (pattern: `openAuthorizationAndWaitForReturn`). + markLaunched(); + const integrationUrl = + platform === 'gitlab' + ? getGitLabIntegrationUrl(WEB_BASE_URL, organizationId ?? undefined) + : getBitbucketIntegrationUrl(WEB_BASE_URL, organizationId ?? ''); + const trigger = await openAuthorizationAndWaitForReturn(Platform.OS, integrationUrl); + if (trigger === 'sheet-close') { + clearLaunch(); + await status.refetch(); + } + // Android: the AppState listener in `useExternalAuthReturn` refetches + // when the app returns to foreground; the sentinel stays set until it + // consumes the launch. + } catch { + // The browser failed to open — clear the sentinel so a later unrelated + // foreground doesn't trigger a stray refetch, and keep the gate showing. + clearLaunch(); + } finally { + setConnecting(false); + } + }; + + const view = selectPrReviewGateView({ + platform, + isError: status.isError, + isLoading: status.isPending, + connected: status.data?.connected === true, + // `revoked` is the GitHub App's vocabulary; provider statuses only ever + // answer connected / not connected. + revoked: false, + organizationId, + }); + + if (view === 'org-only') { + return ( + + + + + ); + } + + if (view === 'error') { + return ( + + + { + void status.refetch(); + }} + isRetrying={status.isFetching} + /> + + ); + } + + if (view === 'loading') { + return ( + + + + + + + ); + } + + if (view === 'connect') { + const title = platform === 'gitlab' ? t('common.connectGitlab') : t('common.connectBitbucket'); + return ( + + + { + void handleConnect(); + }} + > + {connecting ? ( + + ) : ( + + )} + {title} + + } + /> + + ); + } + + return <>{children}; +} diff --git a/apps/mobile/src/components/pr-review/pr-review-discussion-tab-view.test.ts b/apps/mobile/src/components/pr-review/pr-review-discussion-tab-view.test.ts index d25d35521a..ad7450159e 100644 --- a/apps/mobile/src/components/pr-review/pr-review-discussion-tab-view.test.ts +++ b/apps/mobile/src/components/pr-review/pr-review-discussion-tab-view.test.ts @@ -5,6 +5,7 @@ import { selectDiscussionTabView } from './pr-review-discussion-tab-view'; const base = { firstPageErrorState: null, isPending: false, + isPaused: false, isEmpty: false, }; @@ -37,6 +38,15 @@ describe('selectDiscussionTabView', () => { expect(selectDiscussionTabView({ ...base, isPending: true })).toEqual({ kind: 'loading' }); }); + it('returns retryable when the first page is pending but paused', () => { + // A paused fetch (offline, or never started) has no end: the skeleton + // would sit there with no comments, empty state, or error (spot check + // e7). The retryable state carries the working Retry CTA instead. + expect( + selectDiscussionTabView({ ...base, isPending: true, isPaused: true, isEmpty: true }) + ).toEqual({ kind: 'retryable' }); + }); + it('returns empty when there is no error, no pending, and no items', () => { expect(selectDiscussionTabView({ ...base, isEmpty: true })).toEqual({ kind: 'empty' }); }); @@ -50,6 +60,7 @@ describe('selectDiscussionTabView', () => { selectDiscussionTabView({ firstPageErrorState: { kind: 'permission' }, isPending: true, + isPaused: false, isEmpty: true, }) ).toEqual({ kind: 'permission' }); diff --git a/apps/mobile/src/components/pr-review/pr-review-discussion-tab-view.ts b/apps/mobile/src/components/pr-review/pr-review-discussion-tab-view.ts index 326b006f9c..5b47a60830 100644 --- a/apps/mobile/src/components/pr-review/pr-review-discussion-tab-view.ts +++ b/apps/mobile/src/components/pr-review/pr-review-discussion-tab-view.ts @@ -11,15 +11,24 @@ export type DiscussionTabView = { export function selectDiscussionTabView(args: { firstPageErrorState: { kind: 'permission' | 'not-found' | 'reconnect' | 'retryable' } | null; isPending: boolean; + /** + * The pending first page is paused — offline, or a fetch that will never + * start (spot check e7). + */ + isPaused: boolean; isEmpty: boolean; }): DiscussionTabView { - const { firstPageErrorState, isPending, isEmpty } = args; + const { firstPageErrorState, isPending, isPaused, isEmpty } = args; if (firstPageErrorState) { return { kind: firstPageErrorState.kind }; } if (isPending) { - return { kind: 'loading' }; + // A paused page has no end: the skeleton would sit there with no + // comments, no empty state, and no error. Surface the retryable state so + // the tab always carries an escape. A page in flight — and the one frame + // before the fetch starts — keeps the skeleton. + return { kind: isPaused ? 'retryable' : 'loading' }; } if (isEmpty) { return { kind: 'empty' }; diff --git a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx index f5e6663d17..a5083c2d7a 100644 --- a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx @@ -3,6 +3,8 @@ import { createElement } from 'react'; import TestRenderer, { act } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type ProviderPrRef, ProviderPrScopeProvider } from '@/lib/pr-review/provider-pr-ref'; + import { PrReviewDiscussionTab } from './pr-review-discussion-tab'; const insetsState = vi.hoisted(() => ({ top: 0, bottom: 0, left: 0, right: 0 })); @@ -24,6 +26,7 @@ const discussionState = vi.hoisted(() => ({ query: { isPending: false, isFetching: false, + isPaused: false, hasNextPage: false, isFetchingNextPage: false, fetchNextPage: vi.fn(), @@ -81,10 +84,19 @@ const BASE_PROPS = { onRequestFiles: vi.fn(() => undefined), }; -function mountTab(): TestRenderer.ReactTestRenderer { +/** Mounts the tab, optionally under a provider scope (no scope = GitHub). */ +function mountTab(scopeRef?: ProviderPrRef): TestRenderer.ReactTestRenderer { + const tab = createElement(PrReviewDiscussionTab, BASE_PROPS); + const tree = scopeRef ? ( + + {tab} + + ) : ( + tab + ); const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; act(() => { - ref.current = TestRenderer.create(createElement(PrReviewDiscussionTab, BASE_PROPS)); + ref.current = TestRenderer.create(tree); }); const renderer = ref.current; if (!renderer) { @@ -106,19 +118,10 @@ function bottomPaddedViews( ); } -function expectSinglePadding(renderer: TestRenderer.ReactTestRenderer, expected: number): void { - const views = bottomPaddedViews(renderer); - expect(views).toHaveLength(1); - const view = views[0]; - if (!view) { - throw new Error('expected a padded View'); - } - expect((view.props.style as { paddingBottom?: number }).paddingBottom).toBe(expected); -} - function resetState(): void { discussionState.query.isPending = false; discussionState.query.isFetching = false; + discussionState.query.isPaused = false; discussionState.query.hasNextPage = false; discussionState.query.isFetchingNextPage = false; discussionState.threads = []; @@ -132,17 +135,21 @@ function expectCtaPresence(renderer: TestRenderer.ReactTestRenderer, present: bo expect(ctas.length > 0).toBe(present); } -describe('PrReviewDiscussionTab loading skeleton side insets (landscape)', () => { - beforeEach(() => { - insetsState.bottom = 0; - insetsState.left = 0; - insetsState.right = 0; - resetState(); - }); +beforeEach(() => { + insetsState.bottom = 0; + insetsState.left = 0; + insetsState.right = 0; + vi.clearAllMocks(); + resetState(); +}); +describe('PrReviewDiscussionTab loading skeleton side insets (landscape)', () => { function loadingWrapperStyle(): Record { discussionState.query.isPending = true; - const views = bottomPaddedViews(mountTab()); + const renderer = mountTab(); + // The loading skeleton never renders the comment CTA bar. + expectCtaPresence(renderer, false); + const views = bottomPaddedViews(renderer); expect(views).toHaveLength(1); const view = views[0]; if (!view) { @@ -151,36 +158,33 @@ describe('PrReviewDiscussionTab loading skeleton side insets (landscape)', () => return view.props.style as Record; } - it('keeps exactly the current style keys at zero portrait insets', () => { - const style = loadingWrapperStyle(); - - // Spread only when nonzero: the `px-4` className gutter must survive - // portrait untouched (inline style wins over className). - expect(style.paddingLeft).toBeUndefined(); - expect(style.paddingRight).toBeUndefined(); - expect(style.paddingBottom).toBe(32); - }); - - it('clears the sensor housing with the landscape side insets', () => { - insetsState.left = 47; - insetsState.right = 59; + it.each([ + { left: 0, right: 0, pl: undefined, pr: undefined }, + { left: 47, right: 59, pl: 47, pr: 59 }, + ] as const)('pads the loading wrapper (left=$left right=$right)', ({ left, right, pl, pr }) => { + insetsState.left = left; + insetsState.right = right; const style = loadingWrapperStyle(); - expect(style.paddingLeft).toBe(47); - expect(style.paddingRight).toBe(59); - // The skeleton gutter swap is horizontal-only: the paddingBottom that - // clears the system bar is unchanged. + // Spread only when nonzero: the `px-4` className gutter must survive + // portrait untouched (inline style wins over className), and the + // horizontal swap must not change the paddingBottom that clears the bar. + expect(style.paddingLeft).toBe(pl); + expect(style.paddingRight).toBe(pr); expect(style.paddingBottom).toBe(32); }); }); describe('PrReviewDiscussionTab full-body states', () => { - beforeEach(() => { - insetsState.bottom = 0; - pushMock.mockClear(); - resetState(); - }); + // Wording, not layout: the same states below must never call a GitLab + // merge request a "pull request". Every other state's copy is already + // provider-neutral, so it stays on one key. + const GITLAB_REF: ProviderPrRef = { + platform: 'gitlab', + projectPath: 'group/sub/repo', + mrIid: 12, + }; it.each(['permission', 'not-found', 'retryable'])('lets QueryError own the %s body', kind => { discussionState.firstPageErrorState = { kind }; @@ -208,10 +212,21 @@ describe('PrReviewDiscussionTab full-body states', () => { expectCtaPresence(renderer, false); }); - it('keeps the loading skeleton padding', () => { + it('escapes a stuck skeleton when the first page is paused, not in flight', () => { + // Spot check e7: the tab showed only skeleton cards — no comments, no + // empty state, no error. A pending page whose fetch is paused has no + // end, so the tab must render the retryable state with a working Retry + // CTA instead of the permanent skeleton. discussionState.query.isPending = true; - expectSinglePadding(mountTab(), 32); - expectCtaPresence(mountTab(), false); + discussionState.query.isPaused = true; + const renderer = mountTab(); + + expect(renderer.root.findAll(node => String(node.type) === 'Skeleton')).toHaveLength(0); + const error = renderer.root.find(node => String(node.type) === 'QueryError'); + act(() => { + (error.props.onRetry as () => void)(); + }); + expect(discussionState.query.refetch).toHaveBeenCalled(); }); it('lets EmptyState own the empty body and keeps its Files action', () => { @@ -245,29 +260,45 @@ describe('PrReviewDiscussionTab full-body states', () => { ).toHaveLength(0); }); + it.each([ + ['permission', GITLAB_REF, true], + ['not-found', GITLAB_REF, true], + ['permission', undefined, false], + ['not-found', undefined, false], + ] as const)('names a merge request in the %s copy', (kind, scope, forMergeRequest) => { + discussionState.firstPageErrorState = { kind }; + const message = String( + mountTab(scope).root.find(node => String(node.type) === 'QueryError').props.message + ); + expect(message.includes('merge request')).toBe(forMergeRequest); + expect(message).not.toContain(forMergeRequest ? 'pull request' : 'merge request'); + }); + + it('names a merge request in the empty state description', () => { + const description = (scopeRef?: ProviderPrRef) => + mountTab(scopeRef).root.find(node => String(node.type) === 'EmptyState').props + .description as string; + expect(description(GITLAB_REF)).toContain('merge request'); + expect(description()).toContain('pull request'); + }); + it('renders the happy list under the comment CTA bar', () => { discussionState.conversation = [{ nodeId: 'c1', createdAt: null }]; const renderer = mountTab(); expect(bottomPaddedViews(renderer)).toHaveLength(0); expect( - renderer.root.findAll( - node => typeof node.type === 'string' && (node.type as string) === 'PrReviewDiscussionList' - ) + renderer.root.findAll(node => String(node.type) === 'PrReviewDiscussionList') ).toHaveLength(1); expectCtaPresence(renderer, true); }); - it('renders the comment CTA bar on the empty view', () => { + it('renders the comment CTA bar on the empty view and opens the composer', () => { const renderer = mountTab(); expect(renderer.root.find(node => String(node.type) === 'EmptyState')).toBeDefined(); expectCtaPresence(renderer, true); - }); - - it('pushes the conversation-comment route from the CTA bar', () => { - const renderer = mountTab(); - const cta = renderer.root.find(node => String(node.type) === 'PrCommentCta'); act(() => { + const cta = renderer.root.find(node => String(node.type) === 'PrCommentCta'); (cta.props.onPress as () => void)(); }); expect(pushMock).toHaveBeenCalledWith({ @@ -286,10 +317,6 @@ describe('PrReviewDiscussionTab full-body states', () => { describe('PrReviewDiscussionTab keyboard-lift gating and reply-scroll wiring', () => { beforeEach(() => { focusState.value = true; - resetState(); - replyScrollFns.markFocus.mockClear(); - replyScrollFns.onViewportLayout.mockClear(); - replyScrollFns.invalidate.mockClear(); }); function mountHappyList(): TestRenderer.ReactTestRenderer { @@ -297,25 +324,19 @@ describe('PrReviewDiscussionTab keyboard-lift gating and reply-scroll wiring', ( return mountTab(); } + function ctaKeyboardLift(): boolean | undefined { + return ( + mountHappyList().root.find(node => String(node.type) === 'PrCommentCta').props as { + keyboardLift?: boolean; + } + ).keyboardLift; + } + it('gates the CTA lift on screen focus: lifted while focused, parked while not', () => { - const focused = mountHappyList(); - expect( - ( - focused.root.find(node => String(node.type) === 'PrCommentCta').props as { - keyboardLift?: boolean; - } - ).keyboardLift - ).toBe(true); + expect(ctaKeyboardLift()).toBe(true); focusState.value = false; - const blurred = mountHappyList(); - expect( - ( - blurred.root.find(node => String(node.type) === 'PrCommentCta').props as { - keyboardLift?: boolean; - } - ).keyboardLift - ).toBe(false); + expect(ctaKeyboardLift()).toBe(false); }); it('feeds the list viewport commits and reply focuses into the scroll hook, and a drag invalidates it', () => { diff --git a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx index 272858eef0..df7fdb3358 100644 --- a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx @@ -7,7 +7,11 @@ // the entire loaded set on every update (R4: a // later page can insert rows mid-list). // - loading: first page in flight; render `Skeleton` -// placeholders matching the row dimensions. +// placeholders matching the row dimensions. A first +// page that is pending but PAUSED (offline, or a fetch +// that will never start) is not "in flight": it falls +// to the retryable state below so the tab never sits +// on a skeleton with no escape (spot check e7). // - retryable: first page failed with a transient error; // render `QueryError` with the standard Retry // CTA wired to `refetch()`. @@ -80,6 +84,7 @@ import { toggleThreadExpanded, } from '@/lib/pr-review/discussion/thread-expansion'; import { usePrReviewDiscussionThreads } from '@/lib/pr-review/discussion/use-pr-review-discussion-threads'; +import { useProviderPrScope } from '@/lib/pr-review/provider-pr-ref'; import { useReplyFocusScroll } from '@/lib/pr-review/discussion/use-reply-focus-scroll'; import { selectDiscussionTabView } from '@/components/pr-review/pr-review-discussion-tab-view'; import { useDetailScreenBottomPadding } from '@/lib/screen-insets'; @@ -114,6 +119,11 @@ export function PrReviewDiscussionTab({ }); const { t } = useTranslation(); + // GitLab calls this a merge request; GitHub and Bitbucket both say pull + // request, so the three provider-named strings below switch on that term + // alone rather than forking the tab per provider. + const { ref } = useProviderPrScope({ owner, repo, number }); + const isMergeRequest = ref.platform === 'gitlab'; const router = useRouter(); const [expansion, setExpansion] = useState>({}); @@ -250,6 +260,10 @@ export function PrReviewDiscussionTab({ const view = selectDiscussionTabView({ firstPageErrorState: retainedContentError ? null : firstPageErrorState, isPending: query.isPending && isEmpty, + // A pending page whose fetch is paused (offline, or a fetch that will + // never start) has no end — the retryable state, not the skeleton (spot + // check e7). + isPaused: query.isPaused, isEmpty, }); @@ -273,7 +287,14 @@ export function PrReviewDiscussionTab({ if (view.kind === 'permission') { return ( - + ); } if (view.kind === 'not-found') { @@ -281,7 +302,11 @@ export function PrReviewDiscussionTab({ ); } @@ -336,7 +361,11 @@ export function PrReviewDiscussionTab({ { + vi.clearAllMocks(); + resetHookSlots(); + store.clear(); +}); + +describe('recents identity across providers', () => { + beforeEach(() => { + seedRecents([ + { ...SAME_TRIPLE, title: 'GitHub one' }, + { + ...SAME_TRIPLE, + title: 'GitLab one', + platform: 'gitlab', + instanceHint: 'https://gitlab.example.com', + }, + { ...SAME_TRIPLE, title: 'Bitbucket one', platform: 'bitbucket' }, + ]); + }); + + it('renders one row per provider with a provider label', async () => { + const tree = await renderLoaded(); + const rows = findAll(tree, 'View').filter(p => p.props?.testID === 'recent-row'); + expect(rows).toHaveLength(3); + const labels = textValues(tree); + expect(labels).toContain('GitHub'); + expect(labels).toContain('GitLab'); + expect(labels).toContain('Bitbucket'); + // The GitLab row writes the MR identity with the provider's own separator. + expect(labels).toContain('acme/api!7'); + expect(labels).toContain('acme/api#7'); + }); + + it("row presses navigate to the row's own provider route", async () => { + const tree = await renderLoaded(); + const rowPressables = findAll(tree, 'Pressable').filter( + p => p.props?.accessibilityLabel == null + ); + expect(rowPressables).toHaveLength(3); + const pushes: unknown[] = []; + for (const row of rowPressables) { + mocks.push.mockClear(); + (propsOf(row).onPress as () => void)(); + pushes.push(mocks.push.mock.calls[0]?.[0]); + } + // Seed order is stored order: GitHub, GitLab, Bitbucket. + expect(pushes).toEqual([ + '/(app)/pr-review/acme/api/7', + '/(app)/pr-review/gitlab/acme/api/7?instance=https%3A%2F%2Fgitlab.example.com', + '/(app)/pr-review/bitbucket/acme/api/7', + ]); + }); + + it('remove confirms with provider-neutral copy and deletes only the targeted row', async () => { + const tree = await renderLoaded(); + const removeGitLab = find( + tree, + 'Button', + p => p.accessibilityLabel === 'Remove acme/api!7 from recents' + ); + (propsOf(removeGitLab).onPress as () => void)(); + expect(mocks.alert).toHaveBeenCalledWith( + 'Remove from recents?', + 'This review will be removed from your recents.', + expect.arrayContaining([ + expect.objectContaining({ text: 'Cancel' }), + expect.objectContaining({ text: 'Remove' }), + ]) + ); + const alertCall = mocks.alert.mock.calls[0] as + | [string, string, { text: string; onPress?: () => void }[]] + | undefined; + const destructive = alertCall?.[2].find(button => button.text === 'Remove'); + destructive?.onPress?.(); + await flush(); + const remaining = storedRecents(); + expect(remaining).toHaveLength(2); + expect(remaining.map(entry => recentPrKey(entry))).toEqual([ + 'github||acme/api#7', + 'bitbucket||acme/api#7', + ]); + }); + + it('a failed row keeps the retry CTA and provider label', async () => { + store.clear(); + seedRecents([ + { + ...SAME_TRIPLE, + title: 'Broken MR', + platform: 'gitlab', + instanceHint: 'https://gl.acme.dev', + lastResult: 'failed', + }, + ]); + const tree = await renderLoaded(); + expect(textValues(tree)).toContain("Couldn't load"); + const retry = find(tree, 'Button', p => p.accessibilityLabel === 'Retry'); + (propsOf(retry).onPress as () => void)(); + expect(mocks.push).toHaveBeenCalledWith( + '/(app)/pr-review/gitlab/acme/api/7?instance=https%3A%2F%2Fgl.acme.dev' + ); + }); + + it('an empty recents store shows the neutral empty state', async () => { + store.clear(); + seedRecents([]); + const tree = await renderLoaded(); + const empty = find(tree, 'EmptyState', () => true); + expect(empty.props?.title).toBe('No recent reviews'); + expect(empty.props?.description).toBe( + "Paste a link above to start a review — it'll show up here next time." + ); + }); + + it('the recents load renders into an indicator while pending', () => { + // getRecentPrs is still in flight on the first call: the body is the + // spinner, never a blank that would jump the layout on arrival. + store.set('pr-review-recents', JSON.stringify([{ ...SAME_TRIPLE, title: 'X' }])); + const tree = render(); + expect(findAll(tree, 'ActivityIndicator').length).toBeGreaterThan(0); + }); +}); diff --git a/apps/mobile/src/components/pr-review/pr-review-entry-screen-test-utils.ts b/apps/mobile/src/components/pr-review/pr-review-entry-screen-test-utils.ts new file mode 100644 index 0000000000..47350bb3a1 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-entry-screen-test-utils.ts @@ -0,0 +1,253 @@ +// Shared plain-function-call harness for the pr-review entry screen tests. +// The entry screen is rendered without a React renderer: state and refs live +// in a per-test slot array, so calling the component again re-reads what the +// previous call's setters wrote. The vi.mock registrations live here so the +// two test files (URL field, recents) share one wiring; a test file must +// import this module before anything that pulls the screen in. + +import { vi } from 'vitest'; + +import type * as ReactI18next from 'react-i18next'; +import type * as ReactNamespace from 'react'; + +import { PrReviewEntryScreen } from './pr-review-entry-screen'; +import { type RecentPr } from '@/lib/pr-review/recent-prs'; + +import '@/i18n'; + +const harnessMocks = vi.hoisted(() => ({ + push: vi.fn(), + alert: vi.fn(), + toastError: vi.fn(), + clipboard: { current: '' as string }, +})); + +// A hoisted binding cannot be exported directly; alias it for the test files. +export const mocks = harnessMocks; + +// The store doubles as the recents disk: tests seed it directly and assert +// removals by reading it back. +export const store = new Map(); + +vi.mock('react-i18next', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => { + const i18n = actual.getI18n(); + return { t: i18n.t.bind(i18n), i18n }; + }, + }; +}); + +vi.mock('expo-router', () => ({ + useFocusEffect: (effect: () => (() => void) | undefined) => { + effect(); + }, + useRouter: () => ({ push: harnessMocks.push }), +})); + +vi.mock('expo-clipboard', () => ({ + getStringAsync: async () => { + await Promise.resolve(); + return harnessMocks.clipboard.current; + }, +})); + +vi.mock('expo-secure-store', () => ({ + getItemAsync: async (key: string) => { + await Promise.resolve(); + return store.get(key) ?? null; + }, + setItemAsync: async (key: string, value: string) => { + await Promise.resolve(); + store.set(key, value); + }, + deleteItemAsync: async (key: string) => { + await Promise.resolve(); + store.delete(key); + }, +})); +vi.mock('@/lib/storage-keys', () => ({ PR_REVIEW_RECENTS_KEY: 'pr-review-recents' })); +vi.mock('@/lib/auth/account-metadata-write', () => ({ + writeAccountMetadata: async (_key: string, write: () => Promise) => { + await write(); + }, + deleteAccountMetadata: async (key: string) => { + await Promise.resolve(); + store.delete(key); + }, +})); + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Alert: { alert: harnessMocks.alert }, + Pressable: 'Pressable', + TextInput: 'TextInput', + View: 'View', +})); + +vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' })); +vi.mock('@/components/ui/icons', () => ({ + Clipboard: 'ClipboardIcon', + Link2: 'Link2', + SearchX: 'SearchX', + X: 'X', +})); +vi.mock('@/components/ui/directional-icons', () => ({ + DirectionalChevronRight: 'DirectionalChevronRight', +})); +vi.mock('@/components/pr-review/pr-review-inbox-list', () => ({ + PrReviewInboxList: 'PrReviewInboxList', +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#6F6A61', primaryForeground: '#FFFFFF' }), +})); +vi.mock('@/lib/a11y/announcing-toast', () => ({ + announcingToast: { error: harnessMocks.toastError }, +})); + +// The screen's hooks run without a React renderer: state and refs live in a +// per-test slot array, so calling the component again re-reads what the +// previous call's setters wrote. +let hookSlots: unknown[] = []; +let hookIndex = 0; + +vi.mock('react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useState: (initial: unknown) => { + const index = hookIndex; + hookIndex += 1; + if (!(index in hookSlots)) { + hookSlots[index] = initial; + } + return [ + hookSlots[index], + (next: unknown) => { + hookSlots[index] = + typeof next === 'function' + ? (next as (prev: unknown) => unknown)(hookSlots[index]) + : next; + }, + ]; + }, + useRef: (initial: unknown) => { + const index = hookIndex; + hookIndex += 1; + if (!(index in hookSlots)) { + hookSlots[index] = { current: initial }; + } + return hookSlots[index]; + }, + useCallback: (fn: unknown) => fn, + useMemo: (factory: () => unknown) => factory(), + }; +}); + +export type El = { + type?: unknown; + props?: Record; +}; + +function isElement(value: unknown): value is El { + return typeof value === 'object' && value !== null && 'type' in value && 'props' in value; +} + +function collect(node: unknown, typeName: string, out: El[]): void { + if (node == null) { + return; + } + if (Array.isArray(node)) { + for (const child of node) { + collect(child, typeName, out); + } + return; + } + if (!isElement(node)) { + return; + } + if (node.type === typeName) { + out.push(node); + } + for (const value of Object.values(node.props ?? {})) { + collect(value, typeName, out); + } +} + +export function findAll(tree: unknown, typeName: string): El[] { + const out: El[] = []; + collect(tree, typeName, out); + return out; +} + +export function find( + tree: unknown, + typeName: string, + where: (props: Record) => boolean +): El { + const match = findAll(tree, typeName).find(el => where(el.props ?? {})); + if (!match) { + throw new Error(`no ${typeName} matched the predicate`); + } + return match; +} + +/** A matched element's props, guaranteed present — call handlers through this. */ +export function propsOf(el: El): Record { + if (!el.props) { + throw new Error('element has no props'); + } + return el.props; +} + +export function textValues(tree: unknown): string[] { + return findAll(tree, 'Text') + .map(el => { + const child = el.props?.children; + return typeof child === 'string' ? child : ''; + }) + .filter(value => value.length > 0); +} + +export function render(): unknown { + // Re-rendering restarts the hook order but keeps the slot values, so a + // second call re-reads what the previous call's setters wrote. + hookIndex = 0; + // The component is a plain function call in this harness, not a constructor. + // eslint-disable-next-line new-cap + return PrReviewEntryScreen(); +} + +/** Drop all hook state so the next render starts from its initial values. */ +export function resetHookSlots(): void { + hookSlots = []; + hookIndex = 0; +} + +/** Flush the mocked SecureStore's microtask chain to completion. */ +export async function flush(): Promise { + await new Promise(resolve => { + setTimeout(resolve, 0); + }); +} + +export async function renderLoaded(): Promise { + render(); + // Flush the focus-effect recents load. + await flush(); + return render(); +} + +export function seedRecents(entries: RecentPr[]): void { + store.set('pr-review-recents', JSON.stringify(entries)); +} + +export function storedRecents(): RecentPr[] { + return JSON.parse(store.get('pr-review-recents') ?? '[]') as RecentPr[]; +} diff --git a/apps/mobile/src/components/pr-review/pr-review-entry-screen.test.ts b/apps/mobile/src/components/pr-review/pr-review-entry-screen.test.ts new file mode 100644 index 0000000000..e589d7b1ee --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-entry-screen.test.ts @@ -0,0 +1,138 @@ +// The entry screen is the first route into a review for every provider +// (s7): the field must accept a GitHub PR, a GitLab MR (gitlab.com or a +// self-managed host) and a Bitbucket PR. The URL-field arm of the tests; +// the recents arm lives in pr-review-entry-recents.test.ts and the shared +// plain-function-call harness in pr-review-entry-screen-test-utils.ts. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + find, + findAll, + flush, + mocks, + propsOf, + render, + renderLoaded, + resetHookSlots, + seedRecents, +} from './pr-review-entry-screen-test-utils'; + +beforeEach(() => { + vi.clearAllMocks(); + resetHookSlots(); + mocks.clipboard.current = ''; + seedRecents([]); +}); + +describe('provider-neutral URL field', () => { + it('labels and placeholders name both review nouns, no provider host', async () => { + const tree = await renderLoaded(); + const input = find(tree, 'TextInput', () => true); + expect(input.props?.placeholder).toBe('Pull request or merge request URL'); + expect(input.props?.accessibilityLabel).toBe('Enter a pull request or merge request URL'); + expect(String(input.props?.placeholder)).not.toContain('github'); + }); + + it('opens a GitHub PR URL on the GitHub route', async () => { + const tree = await renderLoaded(); + const input = find(tree, 'TextInput', () => true); + (propsOf(input).onChangeText as (value: string) => void)( + 'https://github.com/octocat/hello-world/pull/42' + ); + const open = render(); + ( + propsOf( + find(open, 'Button', p => p.accessibilityLabel === 'Open pull request or merge request') + ).onPress as () => void + )(); + expect(mocks.push).toHaveBeenCalledWith('/(app)/pr-review/octocat/hello-world/42'); + }); + + it('opens a self-managed GitLab MR on the provider route with its instance', async () => { + const tree = await renderLoaded(); + const input = find(tree, 'TextInput', () => true); + (propsOf(input).onChangeText as (value: string) => void)( + 'https://gitlab.example.com/group/sub/repo/-/merge_requests/9' + ); + const open = render(); + ( + propsOf( + find(open, 'Button', p => p.accessibilityLabel === 'Open pull request or merge request') + ).onPress as () => void + )(); + expect(mocks.push).toHaveBeenCalledWith( + '/(app)/pr-review/gitlab/group/sub/repo/9?instance=https%3A%2F%2Fgitlab.example.com' + ); + }); + + it('opens a Bitbucket PR on the provider route', async () => { + const tree = await renderLoaded(); + const input = find(tree, 'TextInput', () => true); + (propsOf(input).onChangeText as (value: string) => void)( + 'https://bitbucket.org/acme/api/pull-requests/7/overview' + ); + const open = render(); + ( + propsOf( + find(open, 'Button', p => p.accessibilityLabel === 'Open pull request or merge request') + ).onPress as () => void + )(); + expect(mocks.push).toHaveBeenCalledWith('/(app)/pr-review/bitbucket/acme/api/7'); + }); + + it('toasts the provider-neutral invalid copy for a link no provider serves', async () => { + const tree = await renderLoaded(); + const input = find(tree, 'TextInput', () => true); + (propsOf(input).onChangeText as (value: string) => void)('https://example.com/blog/post'); + const open = render(); + ( + propsOf( + find(open, 'Button', p => p.accessibilityLabel === 'Open pull request or merge request') + ).onPress as () => void + )(); + expect(mocks.toastError).toHaveBeenCalledWith('Not a pull request or merge request link'); + expect(mocks.push).not.toHaveBeenCalled(); + }); + + it('paste replaces the field and opens a GitLab MR straight away', async () => { + mocks.clipboard.current = 'https://gitlab.com/acme/api/-/merge_requests/3'; + const tree = await renderLoaded(); + const paste = find( + tree, + 'Pressable', + p => p.accessibilityLabel === 'Paste pull request or merge request link' + ); + await (propsOf(paste).onPress as () => Promise)(); + await flush(); + expect(mocks.push).toHaveBeenCalledWith( + '/(app)/pr-review/gitlab/acme/api/3?instance=https%3A%2F%2Fgitlab.com' + ); + expect(mocks.toastError).not.toHaveBeenCalled(); + }); + + it('paste of plain text keeps the invalid toast without navigating', async () => { + mocks.clipboard.current = 'just some notes'; + const tree = await renderLoaded(); + const paste = find( + tree, + 'Pressable', + p => p.accessibilityLabel === 'Paste pull request or merge request link' + ); + await (propsOf(paste).onPress as () => Promise)(); + await flush(); + expect(mocks.toastError).toHaveBeenCalledWith('Not a pull request or merge request link'); + expect(mocks.push).not.toHaveBeenCalled(); + }); + + it('shows the clear control only once the field has text', async () => { + const before = await renderLoaded(); + expect( + findAll(before, 'Pressable').some(p => p.props?.accessibilityLabel === 'Clear link') + ).toBe(false); + const input = find(before, 'TextInput', () => true); + (propsOf(input).onChangeText as (value: string) => void)('anything'); + const after = render(); + expect(find(after, 'Pressable', p => p.accessibilityLabel === 'Clear link')).toBeTruthy(); + }); +}); diff --git a/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx index 58e42ee93e..cf177f2d42 100644 --- a/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx @@ -9,24 +9,28 @@ import { ActivityIndicator } from '@/components/ui/activity-indicator'; import { EmptyState } from '@/components/empty-state'; import { PrReviewInboxList } from '@/components/pr-review/pr-review-inbox-list'; -import { selectRecentPrRowState } from '@/components/pr-review/recent-pr-row-state'; +import { selectRecentPrRowState } from '@/lib/pr-review/recent-pr-row-state'; import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { announcingToast } from '@/lib/a11y/announcing-toast'; -import { parseGitHubPrUrl } from '@/lib/github-pr-url'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { getPrReviewPath } from '@/lib/profile-agent-navigation'; +import { providerPrRefLabel, providerPrRoutePath } from '@/lib/pr-review/provider-pr-ref'; import { consumePrLinkInputEcho, pushPrLinkInputEcho } from '@/lib/pr-review/pr-link-input-echo'; import { + decidePrLinkOpen, decidePrLinkPaste, prLinkToastClipboardEmptyCopy, prLinkToastInvalidCopy, selectPrLinkClearButtonVisible, } from '@/lib/pr-review/pr-link-paste'; -import { getRecentPrs, type RecentPr, removeRecentPr } from '@/lib/pr-review/recent-prs'; - -const URL_PLACEHOLDER = 'https://github.com/owner/repo/pull/123'; +import { + getRecentPrs, + providerRefFromRecentPr, + type RecentPr, + recentPrKey, + removeRecentPr, +} from '@/lib/pr-review/recent-prs'; export function PrReviewEntryScreen() { const router = useRouter(); @@ -72,16 +76,18 @@ export function PrReviewEntryScreen() { }; const handleSubmit = () => { - const raw = inputValueRef.current; - const parsed = parseGitHubPrUrl(raw.trim()); - if (!parsed) { + const decision = decidePrLinkOpen(inputValueRef.current); + if (decision.kind === 'invalid') { announcingToast.error(prLinkToastInvalidCopy()); return; } - // Navigate straight to the PR route. Recents are written only after an - // authorized payload (the PR screen's backfill effect), so a failed or - // unauthorized open never persists an entry. - router.push(getPrReviewPath(parsed.owner, parsed.repo, parsed.number)); + // Navigate straight to the ref's own provider route. Recents are written + // only after an authorized payload (the review screen's backfill effect), + // so a failed or unauthorized open never persists an entry. A + // self-managed GitLab host parses to a ref whose instanceHint rides as + // the route's `instance` param — the server re-derives the authoritative + // instance, so a mismatched host lands on the clear not-authorized state. + router.push(providerPrRoutePath(decision.ref)); }; const handlePaste = async () => { @@ -105,9 +111,11 @@ export function PrReviewEntryScreen() { }; const handleRecentPress = (entry: RecentPr) => { - // Navigate only. The PR screen's backfill effect updates `lastOpenedAt` - // (and `lastResult`) once an authorized payload loads. - router.push(getPrReviewPath(entry.owner, entry.repo, entry.number)); + // Navigate only. The review screen's backfill effect updates + // `lastOpenedAt` (and `lastResult`) once an authorized payload loads. + // The entry's platform (legacy entries: GitHub) decides the route, so a + // GitLab row opens the GitLab surface, never a same-named GitHub PR. + router.push(providerPrRoutePath(providerRefFromRecentPr(entry))); }; const handleRemoveRecent = (entry: RecentPr) => { @@ -157,11 +165,12 @@ export function PrReviewEntryScreen() { const isLast = index === recent.length - 1; const rowState = selectRecentPrRowState(entry); const removeLabel = t('prReview.entry.removeRecentAccessibility', { - repo: `${entry.owner}/${entry.repo}#${entry.number}`, + repo: providerPrRefLabel(providerRefFromRecentPr(entry)), }); return ( + + {rowState.provider} + {rowState.primary} @@ -240,7 +255,7 @@ export function PrReviewEntryScreen() { {t('prReview.entry.open')} diff --git a/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.test.tsx b/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.test.tsx new file mode 100644 index 0000000000..932337e136 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.test.tsx @@ -0,0 +1,135 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as pr-review-discussion-tab.test.tsx) */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type ProviderPrRef } from '@/lib/pr-review/provider-pr-ref'; + +import { PrReviewFileNavigatorScreen } from './pr-review-file-navigator-screen'; + +const queryState = vi.hoisted(() => ({ + data: null as { headSha: string; counts: { changedFiles: number } } | null, + isLoading: false, + isError: false, + isFetching: false, + error: null as unknown, + refetch: vi.fn(), +})); + +const scopeState: { ref: ProviderPrRef; isReady: boolean } = vi.hoisted(() => ({ + ref: { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 12 }, + isReady: true, +})); + +vi.mock('react-native', () => ({ View: 'View', ActivityIndicator: 'ActivityIndicator' })); +vi.mock('@tanstack/react-query', () => ({ useQuery: () => queryState })); +vi.mock('expo-router', () => ({ + useLocalSearchParams: () => ({}), + useRouter: () => ({ back: vi.fn() }), +})); +vi.mock('@/lib/pr-review/provider-pr-queries', () => ({ + useProviderPrQueries: () => ({ ...scopeState, overviewOptions: () => ({}) }), +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#888' }), +})); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +// The screen renders the UI spinner while loading; the real one reaches the +// motion policy (expo-battery), which stays unmocked in this pure harness. +vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' })); +vi.mock('@/components/pr-review/pr-review-reconnect-notice', () => ({ + PrReviewReconnectNotice: 'PrReviewReconnectNotice', +})); +vi.mock('@/components/pr-review/diff/pr-diff-file-navigator', () => ({ + PrDiffFileNavigator: 'PrDiffFileNavigator', +})); + +function mountScreen(): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create(createElement(PrReviewFileNavigatorScreen)); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +function find(renderer: TestRenderer.ReactTestRenderer, type: string) { + return renderer.root.find(node => String(node.type) === type); +} + +function trpcError(code: string): unknown { + return Object.assign(new Error(code), { data: { code }, shape: { data: { code } } }); +} + +describe('PrReviewFileNavigatorScreen states', () => { + beforeEach(() => { + queryState.data = null; + queryState.isLoading = false; + queryState.isError = false; + queryState.isFetching = false; + queryState.error = null; + queryState.refetch.mockClear(); + scopeState.ref = { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 12 }; + scopeState.isReady = true; + }); + + it('titles the sheet with the provider ref, not a GitHub triple', () => { + expect(find(mountScreen(), 'ScreenHeader').props.eyebrow).toBe('group/sub/repo!12'); + }); + + it('shows one loading indicator while the first load is in flight', () => { + queryState.isLoading = true; + const renderer = mountScreen(); + expect(renderer.root.findAll(node => String(node.type) === 'ActivityIndicator')).toHaveLength( + 1 + ); + expect(renderer.root.findAll(node => String(node.type) === 'QueryError')).toHaveLength(0); + }); + + it('hands the navigator the resolved provider identity on the happy path', () => { + queryState.data = { headSha: 'abc123', counts: { changedFiles: 4 } }; + const navigator = find(mountScreen(), 'PrDiffFileNavigator'); + expect(navigator.props).toMatchObject({ + owner: 'group/sub', + repo: 'repo', + number: 12, + headSha: 'abc123', + changedFiles: 4, + }); + }); + + it('offers a retry only for a transient failure', () => { + queryState.isError = true; + queryState.error = trpcError('INTERNAL_SERVER_ERROR'); + const error = find(mountScreen(), 'QueryError'); + expect(error.props.variant).toBe('server'); + act(() => { + (error.props.onRetry as () => void)(); + }); + expect(queryState.refetch).toHaveBeenCalled(); + }); + + it.each([ + ['FORBIDDEN', 'permission'], + ['NOT_FOUND', 'not-found'], + ])('renders %s as a terminal state with no retry', (code, variant) => { + queryState.isError = true; + queryState.error = trpcError(code); + const error = find(mountScreen(), 'QueryError'); + expect(error.props.variant).toBe(variant); + expect(error.props.onRetry).toBeUndefined(); + }); + + it('points a broken connection at the reconnect notice instead of a retry', () => { + queryState.isError = true; + queryState.error = trpcError('PRECONDITION_FAILED'); + const renderer = mountScreen(); + expect(find(renderer, 'PrReviewReconnectNotice')).toBeDefined(); + expect(renderer.root.findAll(node => String(node.type) === 'QueryError')).toHaveLength(0); + }); +}); diff --git a/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx index 2fa40b135c..d05acc8567 100644 --- a/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx @@ -7,11 +7,14 @@ import { ActivityIndicator } from '@/components/ui/activity-indicator'; import { CenteredState } from '@/components/centered-state'; import { PrDiffFileNavigator } from '@/components/pr-review/diff/pr-diff-file-navigator'; +import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { classifyPrReviewQueryState } from '@/lib/pr-review/classify-pr-review-query-state'; +import { useProviderPrQueries } from '@/lib/pr-review/provider-pr-queries'; +import { providerPrRefLabel, providerPrTriple } from '@/lib/pr-review/provider-pr-ref'; import { parseParam } from '@/lib/route-params'; -import { useTRPC } from '@/lib/trpc'; type Params = { owner: string; @@ -23,29 +26,38 @@ type Params = { * File-navigator formSheet route. Fetches the PR overview for the head SHA * (the navigator keys viewed state + the diff list on it) and mounts the S6c * navigator content. Rendered inside the `[number]` layout's formSheet stack. + * + * Provider-agnostic (s5): the same sheet is a sibling of the provider route's + * stack, where the identity comes from the published scope rather than from + * `owner`/`repo`/`number` params. The GitHub params below stay the fallback + * scope, so the GitHub route reaches the exact same query it did before. */ export function PrReviewFileNavigatorScreen() { const router = useRouter(); const colors = useThemeColors(); const { t } = useTranslation(); const params = useLocalSearchParams(); - const owner = parseParam(params.owner) ?? ''; - const repo = parseParam(params.repo) ?? ''; const rawNumber = parseParam(params.number) ?? ''; - const number = Number.parseInt(rawNumber, 10); + const queries = useProviderPrQueries({ + owner: parseParam(params.owner) ?? '', + repo: parseParam(params.repo) ?? '', + number: Number.parseInt(rawNumber, 10), + }); + const { owner, repo, number } = providerPrTriple(queries.ref); - const trpc = useTRPC(); - const pr = useQuery( - trpc.githubPrReview.getPullRequest.queryOptions( - { owner, repo, number }, - { enabled: Boolean(owner) && Boolean(repo) && Number.isInteger(number) && number > 0 } - ) - ); + // The route layout above validates the identity before this sheet can + // mount; the guard stays as the belt-and-braces it always was, ANDed with + // the scope readiness a Bitbucket ref carries. + const hasIdentity = Boolean(owner) && Boolean(repo) && Number.isInteger(number) && number > 0; + const pr = useQuery({ + ...queries.overviewOptions(), + enabled: queries.isReady && hasIdentity, + }); const header = ( { router.back(); @@ -77,9 +89,8 @@ export function PrReviewFileNavigatorScreen() { ) : ( - { void pr.refetch(); }} @@ -89,3 +100,39 @@ export function PrReviewFileNavigatorScreen() { ); } + +/** + * The sheet's failure states, split the same way the Overview and Discussion + * bodies split them: a permission denial, a missing PR/MR and a broken + * connection are terminal and carry no retry, because retrying the identical + * request cannot change any of them. Only a transient failure gets the CTA. + */ +function NavigatorError({ + error, + onRetry, + isRetrying, +}: Readonly<{ error: unknown; onRetry: () => void; isRetrying: boolean }>) { + const { t } = useTranslation(); + const state = error === null ? null : classifyPrReviewQueryState(error); + if (state?.kind === 'permission') { + return ; + } + if (state?.kind === 'not-found') { + return ; + } + if (state?.kind === 'reconnect') { + return ( + + + + ); + } + return ( + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx b/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx index 87f015aa75..07de0b67b7 100644 --- a/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx @@ -16,6 +16,12 @@ type PrReviewFilesTabProps = { * Files tab: hosts the S6b diff file list (a virtualized FlashList, so the * screen renders this outside its Overview ScrollView). S6c layers the file * navigator sheet and the tablet unified/side-by-side toggle on top of this. + * + * Provider-agnostic (s5): the identity below is the GitHub-shaped triple the + * list and its stores are written against, while the list's own queries + * (`usePrReviewFileListQuery`, `usePrDiffContextLoader`) resolve the real + * `ProviderPrRef` from the provider scope, so the same list renders GitLab + * and Bitbucket diffs without a per-provider copy of this tree. */ export function PrReviewFilesTab({ owner, diff --git a/apps/mobile/src/components/pr-review/pr-review-inbox-list.mounted.test.tsx b/apps/mobile/src/components/pr-review/pr-review-inbox-list.mounted.test.tsx index fb6afe03f2..e36ebe4b1c 100644 --- a/apps/mobile/src/components/pr-review/pr-review-inbox-list.mounted.test.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-inbox-list.mounted.test.tsx @@ -92,8 +92,23 @@ vi.mock('@/lib/hooks/use-theme-colors', () => ({ vi.mock('@/lib/profile-agent-navigation', () => ({ getPrReviewPath: (owner: string, repo: string, number: number) => `/${owner}/${repo}/${number}`, })); -vi.mock('@/lib/pr-review/use-pr-inbox', () => ({ - usePrInbox: () => inboxState, +// `PrReviewInboxList` reads the provider-aware hook; adapt the GitHub-shaped +// inboxState rows to the merged `ProviderInboxRow` shape it renders. +vi.mock('@/lib/pr-review/use-provider-inbox', () => ({ + useProviderInbox: () => ({ + ...inboxState.query, + githubNeedsReconnect: false, + retryFailedPages: vi.fn(), + items: inboxState.items.map(item => ({ + ref: { platform: 'github' as const, owner: item.owner, repo: item.repo, number: item.number }, + key: `${item.owner}/${item.repo}#${item.number}`, + title: item.title, + isDraft: item.isDraft, + updatedAt: item.updatedAt, + })), + firstPageErrorState: inboxState.firstPageErrorState, + laterPageError: inboxState.laterPageError, + }), })); // `@/lib/utils` initializes real i18n; the row only needs timestamp shaping. vi.mock('@/lib/utils', () => ({ diff --git a/apps/mobile/src/components/pr-review/pr-review-inbox-list.tsx b/apps/mobile/src/components/pr-review/pr-review-inbox-list.tsx index c867c6eb75..0f3d9adf0b 100644 --- a/apps/mobile/src/components/pr-review/pr-review-inbox-list.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-inbox-list.tsx @@ -25,13 +25,17 @@ import { DirectionalChevronRight } from '@/components/ui/directional-icons'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { getPrReviewPath } from '@/lib/profile-agent-navigation'; -import { usePrInbox } from '@/lib/pr-review/use-pr-inbox'; +import { + providerPrRefLabel, + providerPrRoutePath, + providerPrTermKey, +} from '@/lib/pr-review/provider-pr-ref'; +import { type ProviderInboxRow, useProviderInbox } from '@/lib/pr-review/use-provider-inbox'; import { parseTimestamp, timeAgo } from '@/lib/utils'; const SKELETON_ROW_COUNT = 5; -type InboxItem = ReturnType['items'][number]; +type InboxItem = ProviderInboxRow; type PrReviewInboxListProps = { /** The "Paste a PR link" block, rendered above the Inbox eyebrow. */ @@ -41,13 +45,31 @@ type PrReviewInboxListProps = { }; export function PrReviewInboxList({ header, recents }: Readonly) { - const { query, items, firstPageErrorState, laterPageError } = usePrInbox(true); + const inbox = useProviderInbox(true); + // Two different retries, because they recover two different failures: the + // empty-state CTA re-runs the inbox from scratch, while the footer CTA must + // load only the page (or the provider) that failed — re-fetching pages the + // list already shows would never load the missing one. + const handleRetry = () => { + inbox.refetch(); + }; + const handleRetryMore = () => { + inbox.retryFailedPages(); + }; + const reconnectOnly = inbox.githubNeedsReconnect && !inbox.isPending && inbox.items.length === 0; const view = selectPrInboxView({ - isLoading: query.isPending, - itemCount: items.length, - firstPageErrorState, - laterPageError, + isLoading: inbox.isPending, + itemCount: inbox.items.length, + firstPageErrorState: + inbox.firstPageErrorState ?? (reconnectOnly ? { kind: 'reconnect' } : null), + laterPageError: inbox.laterPageError, }); + // A provider outage while the merged list happens to be empty is still a + // retryable failure, not "no review requests": keep the footer retry so the + // failing provider has a CTA the empty state itself must not carry. + const showLoadMoreRetry = + view.showLoadMoreRetry || + ((view.kind === 'empty' || view.kind === 'reconnect') && inbox.laterPageError); // Landscape: side insets keep inbox rows and the px-6 header/footer // content clear of the sensor housing; portrait insets are zero, so the @@ -60,8 +82,8 @@ export function PrReviewInboxList({ header, recents }: Readonly `${item.owner}/${item.repo}#${item.number}`} + data={view.kind === 'happy' ? inbox.items : []} + keyExtractor={item => item.key} renderItem={({ item }) => } ListHeaderComponent={ @@ -70,30 +92,21 @@ export function PrReviewInboxList({ header, recents }: Readonly } ListEmptyComponent={ - { - void query.refetch(); - }} - isRetrying={query.isFetching} - /> + } ListFooterComponent={ - {view.showLoadMoreRetry ? ( - { - void query.fetchNextPage(); - }} - /> + {inbox.githubNeedsReconnect && view.kind !== 'reconnect' ? ( + ) : null} + {showLoadMoreRetry ? : null} {recents} } onEndReached={() => { - if (query.hasNextPage && !query.isFetchingNextPage) { - void query.fetchNextPage(); + if (inbox.hasNextPage && !inbox.isFetchingNextPage) { + inbox.fetchNextPage(); } }} onEndReachedThreshold={0.5} @@ -135,12 +148,14 @@ function InboxRow({ item }: Readonly<{ item: InboxItem }>) { const colors = useThemeColors(); const { t } = useTranslation(); const updatedLabel = timeAgo(parseTimestamp(item.updatedAt)); - const rowLabel = `${item.owner}/${item.repo}#${item.number}`; + // `group/sub/repo!12` on GitLab, `owner/repo#7` elsewhere — the row says + // which provider it came from before the term chip repeats it in words. + const rowLabel = providerPrRefLabel(item.ref); return ( { - router.push(getPrReviewPath(item.owner, item.repo, item.number)); + router.push(providerPrRoutePath(item.ref)); }} accessibilityRole="button" accessibilityLabel={rowLabel} @@ -152,15 +167,10 @@ function InboxRow({ item }: Readonly<{ item: InboxItem }>) { - {item.owner}/{item.repo}#{item.number} · {updatedLabel} + {rowLabel} · {updatedLabel} - {item.isDraft ? ( - - - {t('common.draft')} - - - ) : null} + + {item.isDraft ? : null} @@ -168,6 +178,16 @@ function InboxRow({ item }: Readonly<{ item: InboxItem }>) { ); } +function InboxChip({ label }: Readonly<{ label: string }>) { + return ( + + + {label} + + + ); +} + function InboxEmpty({ view, onRetry, diff --git a/apps/mobile/src/components/pr-review/pr-review-merge-screen.mounted.test.tsx b/apps/mobile/src/components/pr-review/pr-review-merge-screen.mounted.test.tsx new file mode 100644 index 0000000000..5efa4cd26b --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-merge-screen.mounted.test.tsx @@ -0,0 +1,297 @@ +// The merge screen gates its sheet on the provider reads' DATA (ux2): an +// errored `providerReview.getMergeState` must not mount the GitLab sheet +// without its restrictions list, and an errored `providerReview.getCapabilities` +// must not mount the Bitbucket auto-merge sheet without its capability banner. +// The gate is data presence, not `isSuccess`: a foreground refresh whose +// refetch fails retains the last good read, and the open sheet must stay +// mounted on that retained data rather than drop the user's typed message. +// The failure body's Retry refetches the failed provider reads alongside the +// overview. Mounted with a keyed `useQuery` mock so each read can settle into a +// different state than its siblings. + +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom); see src/components/pr-review/pr-review-submit.test.tsx */ +/* eslint-disable require-await, @typescript-eslint/require-await -- the fake refetch factories settle without await because they resolve immediately */ + +import type * as ReactQuery from '@tanstack/react-query'; +import { type ReactNode } from 'react'; +import { type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import '@/i18n'; +import { PrReviewMergeScreen } from './pr-review-merge-screen'; +import { type ProviderPrScope, ProviderPrScopeProvider } from '@/lib/pr-review/provider-pr-ref'; +import { renderWithProviders } from '@/test/render-with-providers'; + +type MockQueryResult = { + data: unknown; + isLoading: boolean; + isPending: boolean; + isSuccess: boolean; + isError: boolean; + isFetching: boolean; + refetch: () => Promise; +}; + +type ResultKey = 'overview' | 'mergeState' | 'capabilities'; + +type MockState = { + results: Partial>; + params: Record; + scope: ProviderPrScope | null; +}; + +const mock = vi.hoisted( + (): MockState => ({ + results: {}, + params: {}, + scope: null, + }) +); + +function mockResult(overrides: Partial = {}): MockQueryResult { + return { + data: undefined, + isLoading: false, + isPending: false, + isSuccess: true, + isError: false, + isFetching: false, + refetch: vi.fn(async () => undefined), + ...overrides, + }; +} + +function resultOf(key: ResultKey): MockQueryResult { + const query = mock.results[key]; + if (query === undefined) { + throw new Error(`the test did not set a mock result for the ${key} read`); + } + return query; +} + +vi.mock('@tanstack/react-query', async importOriginal => ({ + ...(await importOriginal()), + useQuery: (options: { queryKey: readonly unknown[] }) => + mock.results[String(options.queryKey[0]) as ResultKey], +})); +vi.mock('expo-router', () => ({ + useRouter: () => ({ back: vi.fn(), push: vi.fn() }), + useLocalSearchParams: () => mock.params, +})); +vi.mock('react-native', () => ({ + View: 'View', + ActivityIndicator: 'ActivityIndicator', +})); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); +// The screen renders the UI spinner while the overview loads; the real one +// reaches the motion policy (expo-battery), unmocked in this harness. +vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' })); +vi.mock('@/components/pr-review/pr-form-sheet-chrome', () => ({ + PrFormSheetHeader: 'PrFormSheetHeader', +})); +vi.mock('@/components/pr-review/merge/pr-merge-sheet', () => ({ + PrMergeSheet: 'PrMergeSheet', + providerPrNounKey: (platform: string) => + platform === 'gitlab' ? 'common.mergeRequest' : 'common.pullRequest', +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({}) })); +vi.mock('@/lib/trpc', () => ({ + trpcClient: {}, + useTRPC: () => ({ + githubPrReview: { getPullRequest: { queryOptions: () => ({ queryKey: ['overview'] }) } }, + providerReview: { + getPullRequest: { queryOptions: () => ({ queryKey: ['overview'] }) }, + getMergeState: { queryOptions: () => ({ queryKey: ['mergeState'] }) }, + getCapabilities: { queryOptions: () => ({ queryKey: ['capabilities'] }) }, + }, + }), +})); + +function ScopeWrapper({ children }: Readonly<{ children: ReactNode }>) { + const { scope } = mock; + if (scope === null) { + throw new Error('the test did not set a provider scope'); + } + return {children}; +} + +const gitlabMergeState = { + canMerge: false, + approvalsRequired: 2, + pipelineMustSucceed: true, + conflicts: false, + blockedReasons: [{ code: 'approvals', message: '2 approvals required' }], +}; + +const overviewData = { + headSha: 'abc123', + headRef: 'feature', + isCrossRepo: false, + prNodeId: 'gitlab:group/repo!12', + title: 'Ship it', + bodyMarkdown: '', + baseRef: 'main', + repo: { allowMergeCommit: true, allowSquashMerge: true, allowRebaseMerge: false }, +}; + +const autoMergeUnsupported = { supported: false, reason: 'Workspace has no auto-merge' }; + +function gitlabMergeParams() { + mock.params = { platform: 'gitlab', identity: ['group', 'repo', '12'] }; + mock.scope = { + ref: { platform: 'gitlab', projectPath: 'group/repo', mrIid: 12 }, + organizationId: null, + }; +} + +function bitbucketAutoMergeParams() { + mock.params = { platform: 'bitbucket', identity: ['ws', 'repo', '7'], mode: 'enable-auto-merge' }; + mock.scope = { + ref: { platform: 'bitbucket', workspace: 'ws', repoSlug: 'repo', prId: 7 }, + organizationId: 'org-1', + }; +} + +async function renderScreen() { + return renderWithProviders(, { wrapper: ScopeWrapper }); +} + +function findSheet(renderer: ReactTestRenderer) { + return renderer.root.findAll(node => String(node.type) === 'PrMergeSheet'); +} + +function oneSheet(renderer: ReactTestRenderer): ReactTestInstance { + const [sheet] = findSheet(renderer); + if (sheet === undefined) { + throw new Error('the merge sheet did not mount'); + } + return sheet; +} + +function findError(renderer: ReactTestRenderer) { + return renderer.root.findAll(node => String(node.type) === 'QueryError'); +} + +function oneError(renderer: ReactTestRenderer): ReactTestInstance { + const [error] = findError(renderer); + if (error === undefined) { + throw new Error('the failure body did not render'); + } + return error; +} + +beforeEach(() => { + vi.clearAllMocks(); + mock.results = { + overview: mockResult({ data: overviewData }), + mergeState: mockResult({ data: gitlabMergeState }), + capabilities: mockResult({ data: { autoMerge: autoMergeUnsupported } }), + }; +}); + +describe('PrReviewMergeScreen provider-read gating', () => { + it('keeps the GitLab sheet unmounted when getMergeState fails while the overview succeeds', async () => { + gitlabMergeParams(); + mock.results.mergeState = mockResult({ data: undefined, isSuccess: false, isError: true }); + const { renderer, unmount } = await renderScreen(); + expect(findSheet(renderer)).toHaveLength(0); + expect(findError(renderer)).toHaveLength(1); + unmount(); + }); + + it('refetches the failed merge-state read alongside the overview on Retry', async () => { + gitlabMergeParams(); + mock.results.mergeState = mockResult({ data: undefined, isSuccess: false, isError: true }); + const { renderer, unmount } = await renderScreen(); + const error = oneError(renderer); + (error.props.onRetry as () => void)(); + await vi.waitFor(() => { + expect(resultOf('overview').refetch).toHaveBeenCalledOnce(); + expect(resultOf('mergeState').refetch).toHaveBeenCalledOnce(); + }); + expect(resultOf('capabilities').refetch).not.toHaveBeenCalled(); + unmount(); + }); + + it('keeps the Bitbucket auto-merge sheet unmounted when getCapabilities fails', async () => { + bitbucketAutoMergeParams(); + mock.results.mergeState = mockResult({ data: { ...gitlabMergeState, canMerge: true } }); + mock.results.capabilities = mockResult({ data: undefined, isSuccess: false, isError: true }); + const { renderer, unmount } = await renderScreen(); + expect(findSheet(renderer)).toHaveLength(0); + expect(findError(renderer)).toHaveLength(1); + const error = oneError(renderer); + (error.props.onRetry as () => void)(); + await vi.waitFor(() => { + expect(resultOf('overview').refetch).toHaveBeenCalledOnce(); + expect(resultOf('capabilities').refetch).toHaveBeenCalledOnce(); + }); + // The merge-state read succeeded, so Retry does not refetch it. + expect(resultOf('mergeState').refetch).not.toHaveBeenCalled(); + unmount(); + }); + + it('mounts the sheet with the restrictions list and the capability banner once both reads succeed', async () => { + bitbucketAutoMergeParams(); + mock.results.mergeState = mockResult({ data: { ...gitlabMergeState, canMerge: true } }); + const { renderer, unmount } = await renderScreen(); + expect(findSheet(renderer)).toHaveLength(1); + const sheet = oneSheet(renderer); + expect(sheet.props.mergeState).toEqual({ ...gitlabMergeState, canMerge: true }); + expect(sheet.props.autoMergeCapability).toEqual(autoMergeUnsupported); + expect(findError(renderer)).toHaveLength(0); + unmount(); + }); + + it('shows one loading body while the merge-state read is in flight, not a sheet without it', async () => { + gitlabMergeParams(); + mock.results.mergeState = mockResult({ + data: undefined, + isSuccess: false, + isPending: true, + isLoading: true, + }); + const { renderer, unmount } = await renderScreen(); + expect(findSheet(renderer)).toHaveLength(0); + expect(findError(renderer)).toHaveLength(0); + expect(renderer.root.findAll(node => String(node.type) === 'CenteredState')).toHaveLength(1); + unmount(); + }); + + it('keeps the open GitLab sheet mounted when a refresh refetch of getMergeState fails on retained data', async () => { + // A foreground refresh invalidates the provider reads; the refetch errors + // but the query keeps its last good data. Gating on `isSuccess` would + // unmount the sheet and drop the commit message the user typed. + gitlabMergeParams(); + mock.results.mergeState = mockResult({ + data: gitlabMergeState, + isSuccess: false, + isError: true, + isFetching: true, + }); + const { renderer, unmount } = await renderScreen(); + expect(findSheet(renderer)).toHaveLength(1); + const sheet = oneSheet(renderer); + expect(sheet.props.mergeState).toEqual(gitlabMergeState); + expect(findError(renderer)).toHaveLength(0); + unmount(); + }); + + it('keeps the open Bitbucket auto-merge sheet mounted when a refresh refetch of getCapabilities fails on retained data', async () => { + bitbucketAutoMergeParams(); + mock.results.mergeState = mockResult({ data: { ...gitlabMergeState, canMerge: true } }); + mock.results.capabilities = mockResult({ + data: { autoMerge: autoMergeUnsupported }, + isSuccess: false, + isError: true, + isFetching: true, + }); + const { renderer, unmount } = await renderScreen(); + expect(findSheet(renderer)).toHaveLength(1); + const sheet = oneSheet(renderer); + expect(sheet.props.autoMergeCapability).toEqual(autoMergeUnsupported); + expect(findError(renderer)).toHaveLength(0); + unmount(); + }); +}); diff --git a/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx index 5689accd15..90c99d32c8 100644 --- a/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { type ReactNode } from 'react'; +import { type ReactNode, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator } from '@/components/ui/activity-indicator'; @@ -8,10 +8,23 @@ import { CenteredState } from '@/components/centered-state'; import { PrFormSheetHeader } from '@/components/pr-review/pr-form-sheet-chrome'; import { QueryError } from '@/components/query-error'; -import { PrMergeSheet } from '@/components/pr-review/merge/pr-merge-sheet'; +import { PrMergeSheet, providerPrNounKey } from '@/components/pr-review/merge/pr-merge-sheet'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type PrMergeMethod } from '@/lib/pr-review/merge/merge-blocked-reasons'; import { parseParam } from '@/lib/route-params'; +import { + buildPrMergeStateQueryOptions, + buildPrOverviewQueryOptions, + providerCapabilitiesIdentity, + selectProviderCapabilitiesData, +} from '@/lib/pr-review/provider-pr-queries'; +import { + isProviderScopeReady, + parseProviderPrRoute, + providerPrRefLabel, + providerPrTriple, + useProviderPrScope, +} from '@/lib/pr-review/provider-pr-ref'; import { useTRPC } from '@/lib/trpc'; type Params = { @@ -20,6 +33,10 @@ type Params = { number: string; mode?: string; method?: string; + // Provider route shape (`[platform]/[...identity]/merge`). + platform?: string; + identity?: string[] | string; + instance?: string; }; const MERGE_METHODS = new Set(['merge', 'squash', 'rebase']); @@ -27,39 +44,118 @@ const MERGE_METHODS = new Set(['merge', 'squash', 'rebase']); /** * Merge formSheet route. Reads the PR + mode/method from params, fetches the * overview so the sheet has the repo settings + head SHA fence, and mounts the - * S8 merge sheet. Rendered inside the `[number]` layout's formSheet stack. + * merge sheet. Rendered inside BOTH the GitHub `[number]` layout and the + * provider `[...identity]` layout (s6): on provider arms the s2/s3 merge + * state rides along as the confirmation sheet's restrictions list, and the + * wording follows the connected provider (merge request vs pull request). */ export function PrReviewMergeScreen() { const router = useRouter(); const colors = useThemeColors(); const { t } = useTranslation(); const params = useLocalSearchParams(); - const owner = parseParam(params.owner) ?? ''; - const repo = parseParam(params.repo) ?? ''; - const rawNumber = parseParam(params.number) ?? ''; + + // The provider route carries the identity segments; the GitHub route the + // plain triple. Exactly one parses — the provider layout redirects a + // hand-built `/pr-review/github/...` link to the GitHub route. + const providerRef = useMemo( + () => + parseProviderPrRoute({ + platform: params.platform, + identity: params.identity, + instance: params.instance, + }), + [params.platform, params.identity, params.instance] + ); + const providerTriple = providerRef ? providerPrTriple(providerRef) : null; + const owner = providerTriple ? providerTriple.owner : (parseParam(params.owner) ?? ''); + const repo = providerTriple ? providerTriple.repo : (parseParam(params.repo) ?? ''); + const rawNumber = providerTriple + ? String(providerTriple.number) + : (parseParam(params.number) ?? ''); const number = Number.parseInt(rawNumber, 10); + const mode = params.mode === 'enable-auto-merge' ? 'enable-auto-merge' : 'merge'; const method: PrMergeMethod = MERGE_METHODS.has(params.method as PrMergeMethod) ? (params.method as PrMergeMethod) : 'merge'; - const sheetTitle = - mode === 'enable-auto-merge' - ? t('prReview.merge.enableAutoMerge') - : t('prReview.merge.mergePullRequest'); - const eyebrow = `${owner}/${repo}#${rawNumber}`; + const sheetTitle = (() => { + if (mode === 'enable-auto-merge') { + return t('prReview.merge.enableAutoMerge'); + } + if (providerRef && providerRef.platform !== 'github') { + return t('prReview.merge.mergeTermTitle', { + term: t(providerPrNounKey(providerRef.platform)), + }); + } + return t('prReview.merge.mergePullRequest'); + })(); + const eyebrow = providerRef ? providerPrRefLabel(providerRef) : `${owner}/${repo}#${rawNumber}`; const dismiss = () => { router.back(); }; + // The scope the reads run under: the layout publishes the provider scope in + // context; the GitHub route falls back to the parsed triple, so the GitHub + // query key is byte-identical to the pre-s6 one. + const scope = useProviderPrScope( + owner && repo && Number.isInteger(number) && number > 0 + ? { owner, repo, number } + : { owner: '', repo: '', number: 0 } + ); + const trpc = useTRPC(); - const pr = useQuery( - trpc.githubPrReview.getPullRequest.queryOptions( - { owner, repo, number }, - { enabled: Boolean(owner) && Boolean(repo) && Number.isInteger(number) && number > 0 } - ) + const overviewOptions = useMemo(() => buildPrOverviewQueryOptions(trpc, scope), [trpc, scope]); + const paramsValid = Boolean(owner) && Boolean(repo) && Number.isInteger(number) && number > 0; + const pr = useQuery({ + ...overviewOptions, + enabled: paramsValid && isProviderScopeReady(scope), + }); + + // The auto-merge capability, provider auto-merge arms only: a + // `supported: false` answer (Bitbucket) renders the capability banner. + // The GitHub arm keeps its query disabled — it never touches the + // `providerReview` namespace over the network. + const needsAutoMergeCapability = + providerRef !== null && providerRef.platform !== 'github' && mode === 'enable-auto-merge'; + // The options call sits directly in the body and the result is bound + // before `useQuery`: wrapped in a helper/useMemo closure the linter's + // inference loses the option type, and passed inline the type-checker's + // inference collapses every `.data` read. The data itself is read back + // through the seam's typed selector. + const capabilitiesOptions = trpc.providerReview.getCapabilities.queryOptions( + providerCapabilitiesIdentity(scope), + { + enabled: + needsAutoMergeCapability && scope.ref.platform !== 'github' && isProviderScopeReady(scope), + } ); + const capabilitiesQuery = useQuery(capabilitiesOptions); + const capabilitiesData = selectProviderCapabilitiesData(capabilitiesQuery.data); - if (pr.data) { + // The s2/s3 merge gate, provider arms only (GitHub's gate derives from the + // overview DTO in the merge section — the GitHub arm's query registers + // disabled and never touches the `providerReview` namespace). + const mergeStateOptions = useMemo( + () => buildPrMergeStateQueryOptions(trpc, scope), + [trpc, scope] + ); + const mergeStateQuery = useQuery(mergeStateOptions); + + // The sheet mounts only once its reads HAVE DATA, so its content never + // shifts and a doomed submit is never offered: loading → content happens in + // the screen body, not inside the sheet, and a first-load failure of a + // provider read (no data yet) keeps the sheet unmounted rather than losing + // the restrictions list (getMergeState) or the capability banner + // (getCapabilities). Data presence, not `isSuccess`: a foreground refresh + // whose refetch fails RETAINS the last good read, and gating on success + // would unmount an open sheet — dropping the commit message the user typed + // — over data the sheet still has. + const isProviderArm = scope.ref.platform !== 'github'; + const mergeStateReady = !isProviderArm || mergeStateQuery.data !== undefined; + const capabilitiesReady = !needsAutoMergeCapability || capabilitiesQuery.data !== undefined; + + if (pr.data && mergeStateReady && capabilitiesReady) { return ( { await pr.refetch(); }} @@ -85,20 +186,38 @@ export function PrReviewMergeScreen() { ); } - const body: ReactNode = pr.isLoading ? ( - - - - ) : ( - { - void pr.refetch(); - }} - isRetrying={pr.isFetching} - /> - ); + const body: ReactNode = + pr.isLoading || + (isProviderArm && mergeStateQuery.isLoading) || + (needsAutoMergeCapability && capabilitiesQuery.isLoading) ? ( + + + + ) : ( + { + // Retry recovers every read the screen is waiting on: the overview + // and, on the provider arms, the errored merge gate / capability + // read — a Retry that refetched only the overview could never + // clear the failure that kept the sheet from mounting. Each + // refetch starts as it is called, so the reads run concurrently. + void pr.refetch(); + if (isProviderArm && mergeStateQuery.isError) { + void mergeStateQuery.refetch(); + } + if (needsAutoMergeCapability && capabilitiesQuery.isError) { + void capabilitiesQuery.refetch(); + } + }} + isRetrying={ + pr.isFetching || + (isProviderArm && mergeStateQuery.isFetching) || + (needsAutoMergeCapability && capabilitiesQuery.isFetching) + } + /> + ); return ( <> diff --git a/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx b/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx index 6e982720af..832fac0fa6 100644 --- a/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx @@ -180,7 +180,8 @@ export function PrCountsLine({ additions, deletions, }: Readonly<{ - commits: number; + /** Null where the provider reports no commit count; the chip is dropped. */ + commits: number | null; changedFiles: number; additions: number; deletions: number; @@ -189,12 +190,15 @@ export function PrCountsLine({ const { t } = useTranslation(); return ( - - - - {formatNumber(commits, i18n.language)} {t('prReview.overview.commit', { count: commits })} - - + {commits === null ? null : ( + + + + {formatNumber(commits, i18n.language)}{' '} + {t('prReview.overview.commit', { count: commits })} + + + )} diff --git a/apps/mobile/src/components/pr-review/pr-review-overview.tsx b/apps/mobile/src/components/pr-review/pr-review-overview.tsx index 0647bf47bc..dbed014ed5 100644 --- a/apps/mobile/src/components/pr-review/pr-review-overview.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-overview.tsx @@ -13,7 +13,9 @@ import { QueryError } from '@/components/query-error'; import { MarkdownText } from '@/components/agents/markdown-text'; import { PrOverviewMeta } from '@/components/pr-review/pr-review-meta-parts'; import { PrReviewChecksSection } from '@/components/pr-review/pr-review-checks-section'; +import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice'; import { PrMergeSection } from '@/components/pr-review/merge/pr-merge-section'; +import { PrMergeSectionProvider } from '@/components/pr-review/merge/pr-merge-section-provider'; import { describePrState, formatPrCounts, @@ -28,8 +30,9 @@ import { getGitHubIntegrationUrl } from '@/lib/agent-github-integration'; import { WEB_BASE_URL } from '@/lib/config'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { classifyPrReviewQueryState } from '@/lib/pr-review/classify-pr-review-query-state'; +import { useProviderPrQueries } from '@/lib/pr-review/provider-pr-queries'; import { useCheckGitHubConnection } from '@/lib/pr-review/use-check-github-connection'; -import { trpcClient, useTRPC } from '@/lib/trpc'; +import { trpcClient } from '@/lib/trpc'; const REVIEW_SUBMIT_PATH = '/(app)/pr-review/[owner]/[repo]/[number]/review-submit' as const; @@ -76,13 +79,19 @@ export function PrReviewOverview({ isActive: _isActive, refreshControl, }: PrReviewOverviewProps) { - const trpc = useTRPC(); + const queries = useProviderPrQueries({ owner, repo, number }); const connection = useCheckGitHubConnection(); const router = useRouter(); const colors = useThemeColors(); const { t } = useTranslation(); - const pr = useQuery(trpc.githubPrReview.getPullRequest.queryOptions({ owner, repo, number })); + // GitHub-only affordances: the install/reconnect CTAs and the review-submit + // sheet are GitHub App flows and GitHub-route siblings. On GitLab and + // Bitbucket the same states render without a CTA that cannot work. + const isGitHub = queries.platform === 'github'; + const isMergeRequest = queries.platform === 'gitlab'; + + const pr = useQuery(queries.overviewOptions()); const handleOpenReviewSubmit = useCallback(() => { const href: Href = { @@ -112,12 +121,22 @@ export function PrReviewOverview({ - {t('prReview.installKiloGitHubApp')} - + isGitHub ? ( + + ) : null } /> ); @@ -129,11 +148,31 @@ export function PrReviewOverview({ refreshControl={refreshControl} icon={GitPullRequest} title={t('common.accessDenied')} - description={t('prReview.accessDeniedDescription')} + description={ + isMergeRequest + ? t('prReview.terms.accessDeniedMergeRequest') + : t('prReview.accessDeniedDescription') + } /> ); } if (state.kind === 'reconnect') { + // GitHub keeps the centered empty state with its connection CTA. A + // provider surface reuses the shared reconnect notice instead: it carries + // the provider's own title/message and re-checks that provider's + // connection, because the GitHub copy and CTA cannot recover a GitLab or + // Bitbucket session. + if (!isGitHub) { + return ( + + + + ); + } return ( { void pr.refetch(); }} @@ -232,30 +275,38 @@ export function PrReviewOverview({ - - - {t('prReview.review')} - - - + {isGitHub ? ( + + + {t('prReview.review')} + + + + ) : null} - { - await pr.refetch(); - }} - isRefetching={pr.isFetching} - /> + {isGitHub ? ( + { + await pr.refetch(); + }} + isRefetching={pr.isFetching} + /> + ) : ( + // The provider merge arm (s6): the merge affordance and the + // capability-gated auto-merge row, pushing the ref's own sheet route. + + )} {t('prReview.headLine', { diff --git a/apps/mobile/src/components/pr-review/pr-review-provider-noun.ts b/apps/mobile/src/components/pr-review/pr-review-provider-noun.ts new file mode 100644 index 0000000000..37c2f27219 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-provider-noun.ts @@ -0,0 +1,15 @@ +// The provider's own noun in sentence form (s6). The `prReview.terms.*` +// labels are capitalized for chips and headers, so a mid-sentence +// `{{term}}` interpolation rides the lowercase `common.*` nouns instead — +// "Merge Merge request?" never renders. Standalone (no React imports) so +// the merge sheet, the merge screen and the overview's provider merge arm +// share the one mapping without pulling each other's bundles together. + +import { type ProviderPrPlatform } from '@kilocode/app-shared/provider-review'; + +export function providerPrNounKey( + platform: ProviderPrPlatform +): 'common.mergeRequest' | 'common.pullRequest' { + // i18n-dup-ok: mid-sentence lowercase noun for {{term}} interpolation vs the capitalized standalone prReview.terms.* label; languages case-decline them apart. + return platform === 'gitlab' ? 'common.mergeRequest' : 'common.pullRequest'; +} diff --git a/apps/mobile/src/components/pr-review/pr-review-provider-sheet-href.ts b/apps/mobile/src/components/pr-review/pr-review-provider-sheet-href.ts new file mode 100644 index 0000000000..c787e4dfd4 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-provider-sheet-href.ts @@ -0,0 +1,40 @@ +// The write-sheet routes inside the provider layout (s6). The comment +// composer, the review-submit sheet and the merge sheet are children of the +// PR's own route on every provider: the provider scope and the +// `PendingReviewProvider` queue are published by the provider layout, so a +// provider sheet must be reached through the provider route — pushing the +// GitHub sibling would leave that scope and write to the wrong provider. The +// GitHub route keeps its own literal paths untouched. +import { type Href } from 'expo-router'; + +import { type ProviderPrRef, providerPrRouteSegments } from '@/lib/pr-review/provider-pr-ref'; + +export type ProviderPrSheetRoute = 'comment-composer' | 'review-submit' | 'merge'; + +/** + * The href for one sheet under the ref's own route. A GitLab `instanceHint` + * rides as the `instance` query param — the same param the provider layout + * reads for the base route — so the sheet stays on the instance the reader + * opened. Extra params (composer position, merge mode) ride as query params. + */ +export function providerPrSheetHref( + ref: ProviderPrRef, + sheet: ProviderPrSheetRoute, + params: Record = {} +): Href { + const { platform, identity } = providerPrRouteSegments(ref); + const encoded = identity.map(segment => encodeURIComponent(segment)).join('/'); + // The path is built at runtime, so it never appears in the generated + // typed-routes literal union; like `providerPrHref` (provider-pr-ref.ts), + // the params ride in an encoded query string and the href is the cast + // string. + const queryParts: string[] = []; + if (ref.platform === 'gitlab' && ref.instanceHint) { + queryParts.push(`instance=${encodeURIComponent(ref.instanceHint)}`); + } + for (const [key, value] of Object.entries(params)) { + queryParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + } + const search = queryParts.length > 0 ? `?${queryParts.join('&')}` : ''; + return `/(app)/pr-review/${platform}/${encoded}/${sheet}${search}` as Href; +} diff --git a/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx b/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx index 1ea7172108..87a8bc34e5 100644 --- a/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx @@ -3,24 +3,58 @@ import { View } from 'react-native'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import { useProviderPrScopeOrNull } from '@/lib/pr-review/provider-pr-ref'; import { useCheckGitHubConnection } from '@/lib/pr-review/use-check-github-connection'; +import { useCheckProviderConnection } from '@/lib/pr-review/use-check-provider-connection'; +/** + * The mid-session recovery notice for an expired provider connection. The + * surface that caught the precondition failure renders it; this component + * decides WHICH connection to re-check from the provider scope the route + * published — the GitHub route publishes none, so the GitHub arm (and the + * original copy) is the fallback. A GitLab or Bitbucket surface re-checks + * its own integration status instead, because a GitHub retry can never fix + * an expired GitLab connection. + */ export function PrReviewReconnectNotice() { const connection = useCheckGitHubConnection(); + const providerConnection = useCheckProviderConnection(); + const scope = useProviderPrScopeOrNull(); + const platform = scope?.ref.platform ?? 'github'; + const organizationId = scope?.organizationId ?? null; + const providerPlatform = platform === 'github' ? null : platform; const { t } = useTranslation(); + let title = t('prReview.reconnectNotice.title'); + if (platform === 'gitlab') { + title = t('prReview.reconnectNotice.gitlabTitle'); + } else if (platform === 'bitbucket') { + title = t('prReview.reconnectNotice.bitbucketTitle'); + } + const message = + providerPlatform === null + ? t('prReview.reconnectNotice.message') + : t('prReview.reconnectNotice.providerMessage', { + provider: + platform === 'gitlab' + ? t('common.gitlab') + : t('agentChat.repoPicker.platformBitbucket'), + }); + return ( - - {t('prReview.reconnectNotice.title')} - - {t('prReview.reconnectNotice.message')} + {title} + {message}