From b5a6380a828d63a135df9ae0afc02eef4786e348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 7 Sep 2026 22:26:36 +0200 Subject: [PATCH 1/3] chore: regenerate the two derived universal-link artifacts for the s7 provider rows (kwf bring-mobile-gitlab-and-bitb-3792/c1) --- apps/mobile/src/lib/universal-link-paths.js | 2 + .../.well-known/apple-app-site-association | 2 + .../lib/provider-review/bitbucket-write.ts | 54 +++++++- .../src/lib/provider-review/gitlab-write.ts | 122 ++++++++++++++++-- .../web/src/routers/provider-review-router.ts | 35 +++++ .../src/provider-review/contracts.ts | 16 +++ 6 files changed, 213 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/lib/universal-link-paths.js b/apps/mobile/src/lib/universal-link-paths.js index 7ebf68c6c3..3cd5db6d1d 100644 --- a/apps/mobile/src/lib/universal-link-paths.js +++ b/apps/mobile/src/lib/universal-link-paths.js @@ -20,5 +20,7 @@ export const UNIVERSAL_LINK_PATH_PATTERNS = [ '/organizations/.*/code-reviews', '/organizations/.*/code-reviews/.*', '/organizations/.*/overview', + '/pr-review/gitlab/.*', + '/pr-review/bitbucket/.*', '/pr-review/.*/.*/.*', ]; diff --git a/apps/web/public/.well-known/apple-app-site-association b/apps/web/public/.well-known/apple-app-site-association index 5fc6240f18..e92f38c743 100644 --- a/apps/web/public/.well-known/apple-app-site-association +++ b/apps/web/public/.well-known/apple-app-site-association @@ -21,6 +21,8 @@ { "/": "/organizations/*/code-reviews/review-md", "exclude": true }, { "/": "/organizations/*/code-reviews/*" }, { "/": "/organizations/*/overview" }, + { "/": "/pr-review/gitlab/**" }, + { "/": "/pr-review/bitbucket/**" }, { "/": "/pr-review/*/*/*" } ] } diff --git a/apps/web/src/lib/provider-review/bitbucket-write.ts b/apps/web/src/lib/provider-review/bitbucket-write.ts index c4c557e41f..320620939b 100644 --- a/apps/web/src/lib/provider-review/bitbucket-write.ts +++ b/apps/web/src/lib/provider-review/bitbucket-write.ts @@ -12,7 +12,11 @@ import 'server-only'; import { z } from 'zod'; -import type { ProviderReviewCapabilities } from '@kilocode/app-shared/provider-review'; +import type { + ProviderReviewCapabilities, + ProviderReviewInlineAnchor, + ProviderReviewInlineComment, +} from '@kilocode/app-shared/provider-review'; import { BITBUCKET_REVIEW_CAPABILITIES } from '@kilocode/app-shared/provider-review'; import { authorizeRepository, @@ -126,15 +130,39 @@ async function ownAccountId(access: BitbucketRepositoryAccess): Promise return user.uuid; } -/** Post a top-level comment on the pull request. */ +/** + * The Bitbucket `inline` block for one anchor: RIGHT anchors the destination + * line (`to`), LEFT the source line (`from`); a `startLine` range spans + * `from: startLine` to `to: line`. + */ +function buildInlinePosition(anchor: ProviderReviewInlineAnchor): Record { + if (anchor.startLine !== undefined) { + return { path: anchor.path, from: anchor.startLine, to: anchor.line }; + } + 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 } & BitbucketMutationInput + 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 } }, + body: { + content: { raw: target.body }, + ...(target.anchor ? { inline: buildInlinePosition(target.anchor) } : {}), + }, }); return { done: true, replayed: false }; } catch (error) { @@ -166,19 +194,33 @@ export async function replyToComment( * 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. + * 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; a + * mid-batch failure throws the classified error and the router's + * reconcile-ambiguous handling keeps the ledger from replaying a duplicate. */ export async function submitReview( target: BitbucketPrTarget & { event: 'approve' | 'request_changes' | 'comment'; body?: string; + comments?: ProviderReviewInlineComment[]; } & BitbucketMutationInput ): Promise { - if (target.event === 'comment' && !target.body) { + 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); try { + for (const comment of target.comments ?? []) { + await requestBitbucketJson(access, `${prPath(access, target.prId)}/comments`, { + method: 'POST', + body: { + content: { raw: comment.body }, + inline: buildInlinePosition(comment), + }, + }); + } const accountId = await ownAccountId(access); const state = target.event === 'approve' diff --git a/apps/web/src/lib/provider-review/gitlab-write.ts b/apps/web/src/lib/provider-review/gitlab-write.ts index 1c75fb328f..61575cdbd8 100644 --- a/apps/web/src/lib/provider-review/gitlab-write.ts +++ b/apps/web/src/lib/provider-review/gitlab-write.ts @@ -12,7 +12,11 @@ */ import 'server-only'; -import type { ProviderReviewCapabilities } from '@kilocode/app-shared/provider-review'; +import type { + ProviderReviewCapabilities, + ProviderReviewInlineAnchor, + ProviderReviewInlineComment, +} from '@kilocode/app-shared/provider-review'; import { createMRNote, fetchGitLabMergeRequest, @@ -121,19 +125,101 @@ function mrPath(access: GitLabProjectAccess, mrIid: number): string { return `/api/v4/projects/${encodeURIComponent(access.projectPath)}/merge_requests/${mrIid}`; } -/** Post a top-level comment (project note) on the merge request. */ +/** + * 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 adds `old_line` to a RIGHT anchor. + */ +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; + if (anchor.startLine !== undefined) position.old_line = anchor.startLine; + } else { + position.old_line = anchor.line; + } + return position; +} + +/** + * 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. + */ +async function createInlineDiscussions( + access: GitLabProjectAccess, + mrIid: number, + anchored: Array<{ anchor: ProviderReviewInlineAnchor; body: string }> +): Promise { + const refs = await fetchMrDiffRefs(access, mrIid); + for (const item of anchored) { + await requestGitLabJson(access, `${mrPath(access, mrIid)}/discussions`, { + method: 'POST', + body: { body: item.body, position: buildTextPosition(refs, item.anchor) }, + }); + } +} + +/** + * 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 } & GitLabMutationInput + target: GitLabMrTarget & { body: string; anchor?: ProviderReviewInlineAnchor } & GitLabMutationInput ): Promise { const access = await targetAccess(target); try { - await createMRNote( - access.accessToken, - access.projectPath, - target.mrIid, - target.body, - access.instanceUrl - ); + 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); @@ -160,12 +246,17 @@ export async function replyToDiscussion( /** * 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. + * 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; a + * mid-batch failure throws the classified error and the router's + * reconcile-ambiguous handling keeps the ledger from replaying a duplicate. */ export async function submitReview( target: GitLabMrTarget & { event: 'approve' | 'comment' | 'request_changes'; body?: string; + comments?: ProviderReviewInlineComment[]; } & GitLabMutationInput ): Promise { if (target.event === 'request_changes') { @@ -173,6 +264,13 @@ export async function submitReview( } const access = await targetAccess(target); try { + if (target.comments?.length) { + await createInlineDiscussions( + access, + target.mrIid, + target.comments.map(comment => ({ anchor: comment, body: comment.body })) + ); + } if (target.event === 'approve') { await requestGitLabJson(access, `${mrPath(access, target.mrIid)}/approve`, { method: 'POST', @@ -194,7 +292,7 @@ export async function submitReview( target.body, access.instanceUrl ); - } else { + } else if (!target.comments?.length) { throw new GitLabReviewError('bad_request', 'A comment review needs a body.'); } return { done: true, replayed: false }; diff --git a/apps/web/src/routers/provider-review-router.ts b/apps/web/src/routers/provider-review-router.ts index e9458058d1..6bb5c6562a 100644 --- a/apps/web/src/routers/provider-review-router.ts +++ b/apps/web/src/routers/provider-review-router.ts @@ -92,6 +92,31 @@ const pageCursor = z.string().min(1).max(2048).optional(); // 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'], +} as const; + +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(), @@ -176,6 +201,12 @@ const GetFileLinesInput = providerRefInput({ 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, }); @@ -203,6 +234,10 @@ const ReplyToCommentInput = z.discriminatedUnion('platform', [ 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, }); diff --git a/packages/app-shared/src/provider-review/contracts.ts b/packages/app-shared/src/provider-review/contracts.ts index 988aeb99e9..c097641ffe 100644 --- a/packages/app-shared/src/provider-review/contracts.ts +++ b/packages/app-shared/src/provider-review/contracts.ts @@ -98,6 +98,22 @@ 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, From 500265a4bf1fff43b347fc6b0eb40cc2bfcbd1de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 7 Sep 2026 23:01:04 +0200 Subject: [PATCH 2/3] chore: localize the unsupported-capability reason on mobile (kwf bring-mobile-gitlab-and-bitb-3792/c4) --- .../merge/pr-merge-section-provider.test.tsx | 50 +- .../merge/pr-merge-section-provider.tsx | 17 +- .../pr-review/merge/pr-merge-sheet.test.tsx | 9 + ...-review-capability-banner.mounted.test.tsx | 38 +- .../pr-review/pr-review-capability-banner.tsx | 30 +- .../pr-review-comment-composer.test.tsx | 49 +- .../pr-review/pr-review-comment-composer.tsx | 28 +- .../pr-review/pr-review-submit.test.tsx | 124 ++ .../components/pr-review/pr-review-submit.tsx | 19 +- apps/mobile/src/i18n/locales/en.json | 4 +- .../pr-review/use-pr-review-mutations.test.ts | 145 +- .../lib/pr-review/use-pr-review-mutations.ts | 102 +- .../provider-review/bitbucket-write.test.ts | 765 +++++++--- .../lib/provider-review/bitbucket-write.ts | 239 +-- .../lib/provider-review/gitlab-write.test.ts | 655 +++++--- .../src/lib/provider-review/gitlab-write.ts | 191 ++- .../routers/provider-review-router.test.ts | 862 ++++++++--- .../web/src/routers/provider-review-router.ts | 1329 ++++++++++------- .../src/provider-review/contracts.ts | 51 +- 19 files changed, 3243 insertions(+), 1464 deletions(-) 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 index 6ce4e0b65b..7e8ea65cf3 100644 --- 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 @@ -27,6 +27,14 @@ vi.mock('react-native', () => ({ ActivityIndicator: 'ActivityIndicator', })); +// The capability banner fades in as conditional content (AGENTS.md); the +// DOM-free renderer only needs the animated host as a string component. +vi.mock('react-native-reanimated', () => ({ + default: { View: 'Animated.View' }, + FadeIn: { duration: () => ({}) }, + FadeOut: { duration: () => ({}) }, +})); + vi.mock('@/components/ui/icons', () => ({ AlertTriangle: 'AlertTriangle', GitBranch: 'GitBranch', @@ -94,6 +102,21 @@ function findButton(renderer: TestRenderer.ReactTestRenderer, label: string) { return (button.props as { onPress?: () => void }).onPress; } +function findBanner(renderer: TestRenderer.ReactTestRenderer) { + return renderer.root.findAll( + node => + typeof node.type === 'function' && + (node.type as { name?: string }).name === 'PrReviewCapabilityBanner' + ); +} + +function textsOf(renderer: TestRenderer.ReactTestRenderer): string[] { + return renderer.root + .findAll(node => String(node.type) === 'Text') + .map(node => (node.props as { children?: unknown }).children) + .filter((child): child is string => typeof child === 'string'); +} + describe('PrMergeSectionProvider (s6)', () => { beforeEach(() => { routerPush.mockClear(); @@ -103,24 +126,39 @@ describe('PrMergeSectionProvider (s6)', () => { const renderer = await mount(GITLAB_REF, 'open'); expect(findButtons(renderer, 'Merge merge request')).toBe(1); expect(findButtons(renderer, 'Enable auto-merge')).toBe(1); + // Happy state: a supported capability renders no banner and no + // unavailable copy — the enable CTA is the affordance. + expect(findBanner(renderer)).toHaveLength(0); + expect(textsOf(renderer)).not.toContain('Auto-merge is not available'); renderer.unmount(); }); - it('offers merge with the explicit capability banner on Bitbucket (auto-merge unsupported)', async () => { + it('offers merge with the localized capability banner on Bitbucket (auto-merge unsupported)', async () => { const renderer = await mount(BITBUCKET_REF, 'open'); expect(findButtons(renderer, 'Merge pull request')).toBe(1); + // Non-retryable unhappy state: the banner explains, and carries no CTA. 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' - ); + const [banner] = findBanner(renderer); + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- guard the one-banner invariant with a readable failure + if (!banner) { + throw new Error('the Bitbucket arm renders no capability banner'); + } expect( (banner.props as { capability: { supported: boolean; reason: string } }).capability ).toEqual({ supported: false, reason: 'Bitbucket Cloud does not expose auto-merge in its API', }); + // The section hands catalog copy, not the shared English constant. + expect( + (banner.props as { title?: string; reason?: string }).title + ).toBe('Auto-merge is not available'); + expect( + (banner.props as { title?: string; reason?: string }).reason + ).toBe('Bitbucket Cloud does not expose auto-merge in its API'); + const texts = textsOf(renderer); + expect(texts).toContain('Auto-merge is not available'); + expect(texts).toContain('Bitbucket Cloud does not expose auto-merge in its API'); 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 index cdd9093068..e9620825ac 100644 --- 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 @@ -4,8 +4,8 @@ // 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. +// the explicit capability banner with localized copy — never a dead button +// and never a silent absence. import { useRouter } from 'expo-router'; import { useTranslation } from 'react-i18next'; @@ -40,6 +40,17 @@ export function PrMergeSectionProvider({ prRef, state }: PrMergeSectionProviderP } const autoMerge = providerPrCapabilities(prRef.platform).autoMerge; + // The shared capability carries the provider's raw English reason. This + // section knows which capability it is (auto-merge) and which provider + // (Bitbucket), so it hands the banner catalog copy instead — apps/mobile + // ships translated copy, never a quoted constant. + const autoMergeUnavailableCopy = + prRef.platform === 'bitbucket' && !autoMerge.supported + ? { + title: t('prReview.capabilities.autoMergeUnavailable'), + reason: t('prReview.capabilities.autoMergeUnavailableReason'), + } + : undefined; const mergeLabel = t('prReview.merge.mergeTermTitle', { term: t(providerPrNounKey(prRef.platform)), }); @@ -75,7 +86,7 @@ export function PrMergeSectionProvider({ prRef, state }: PrMergeSectionProviderP {t('prReview.merge.enableAutoMerge')} ) : ( - + )} ); diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx index 7af27b5987..ef0ba7a06c 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx @@ -100,6 +100,15 @@ vi.mock('react-native', () => ({ useWindowDimensions: () => ({ height: 800, width: 400 }), })); +// The capability banner fades in as conditional content (AGENTS.md); this +// suite builds element trees without mounting, so the animated host only +// needs to resolve in the node environment. +vi.mock('react-native-reanimated', () => ({ + default: { View: 'Animated.View' }, + FadeIn: { duration: () => ({}) }, + FadeOut: { duration: () => ({}) }, +})); + vi.mock('expo-haptics', () => ({ notificationAsync: vi.fn(), NotificationFeedbackType: { Success: 'Success' }, diff --git a/apps/mobile/src/components/pr-review/pr-review-capability-banner.mounted.test.tsx b/apps/mobile/src/components/pr-review/pr-review-capability-banner.mounted.test.tsx index 456b4794d1..00b4019370 100644 --- a/apps/mobile/src/components/pr-review/pr-review-capability-banner.mounted.test.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-capability-banner.mounted.test.tsx @@ -20,6 +20,11 @@ vi.mock('react-i18next', async importOriginal => { }); vi.mock('react-native', () => ({ View: 'View' })); +vi.mock('react-native-reanimated', () => ({ + default: { View: 'Animated.View' }, + FadeIn: { duration: () => ({}) }, + FadeOut: { duration: () => ({}) }, +})); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); const UNSUPPORTED: ProviderReviewCapability = { @@ -29,11 +34,14 @@ const UNSUPPORTED: ProviderReviewCapability = { const SUPPORTED: ProviderReviewCapability = { supported: true, reason: '' }; function renderBanner( - capability: ProviderReviewCapability | undefined + capability: ProviderReviewCapability | undefined, + overrides?: { title?: string; reason?: string } ): TestRenderer.ReactTestRenderer { let renderer: TestRenderer.ReactTestRenderer | null = null; act(() => { - renderer = TestRenderer.create(createElement(PrReviewCapabilityBanner, { capability })); + renderer = TestRenderer.create( + createElement(PrReviewCapabilityBanner, { capability, ...overrides }) + ); }); // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- the act() callback runs synchronously; this narrows the definite assignment if (!renderer) { @@ -61,13 +69,37 @@ describe('PrReviewCapabilityBanner', () => { it('announces title and reason together for accessibility', () => { const renderer = renderBanner(UNSUPPORTED); - const view = renderer.root.find(node => (node.type as string) === 'View'); + const view = renderer.root.find(node => (node.type as string) === 'Animated.View'); expect(view.props.accessibilityLabel).toBe( `Not available on this provider: ${UNSUPPORTED.reason}` ); renderer.unmount(); }); + it('renders a localized title/reason override instead of the generic banner copy', () => { + const renderer = renderBanner(UNSUPPORTED, { + title: 'Auto-merge is not available', + reason: 'Bitbucket Cloud does not expose auto-merge in its API', + }); + const texts = textsOf(renderer); + expect(texts).toContain('Auto-merge is not available'); + expect(texts).toContain('Bitbucket Cloud does not expose auto-merge in its API'); + expect(texts).not.toContain('Not available on this provider'); + renderer.unmount(); + }); + + it('keeps capability.reason as the fallback when no override is passed', () => { + const unknownCapability: ProviderReviewCapability = { + supported: false, + reason: 'Some provider answers a reason the catalog does not name', + }; + const renderer = renderBanner(unknownCapability); + const texts = textsOf(renderer); + expect(texts).toContain('Not available on this provider'); + expect(texts).toContain(unknownCapability.reason); + renderer.unmount(); + }); + it('renders nothing for a supported capability — the affordance itself shows', () => { const renderer = renderBanner(SUPPORTED); expect(renderer.toJSON()).toBeNull(); 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 index 1dcda5690c..a003b0dde6 100644 --- a/apps/mobile/src/components/pr-review/pr-review-capability-banner.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-capability-banner.tsx @@ -4,9 +4,14 @@ // 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. +// The provider's raw reason string is only the fallback: a surface that +// recognizes the capability (auto-merge on Bitbucket) passes catalog copy +// through `title`/`reason`, so the explanation is translated, not quoted. +// The banner is conditional content below a section, so it fades in/out on +// mount transitions instead of jumping the layout (AGENTS.md). import { useTranslation } from 'react-i18next'; -import { View } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { type ProviderReviewCapability } from '@kilocode/app-shared/provider-review'; @@ -14,24 +19,35 @@ import { Text } from '@/components/ui/text'; export function PrReviewCapabilityBanner({ capability, -}: Readonly<{ capability: ProviderReviewCapability | undefined }>) { + title, + reason, +}: Readonly<{ + capability: ProviderReviewCapability | undefined; + /** Localized title override for a capability the catalog names. */ + title?: string; + /** Localized reason override; `capability.reason` stays the fallback. */ + reason?: string; +}>) { 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; } + const bannerReason = reason ?? capability.reason; return ( - - {t('prReview.capabilities.banner.title')} + {title ?? t('prReview.capabilities.banner.title')} - {capability.reason} - + {bannerReason} + ); } 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 ec50ea7e99..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 @@ -154,18 +155,6 @@ vi.mock('@/lib/pr-review/pending-review-provider', () => ({ })); vi.mock('@/lib/pr-review/use-pr-review-mutations', () => ({ - formatPendingCommentBody: (item: { - path: string; - line: number; - startLine?: number; - body: string; - }) => { - const location = - item.startLine !== undefined && item.startLine !== item.line - ? `${item.path}:L${item.startLine}–L${item.line}` - : `${item.path}:L${item.line}`; - return `${location}\n\n${item.body}`; - }, useCreateReviewCommentMutation: () => ({ mutateAsync: createCommentMocks.mutateAsync, isPending: createCommentMocks.isPending, @@ -269,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'); }); @@ -303,7 +304,7 @@ describe('PrReviewCommentComposer draft clear rules', () => { 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 body-only post must differ from + // 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 }; @@ -320,7 +321,7 @@ describe('PrReviewCommentComposer provider arm (s6)', () => { vi.clearAllMocks(); }); - it('posts body-only through the provider arm, with the location anchored in the text', async () => { + 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'); @@ -328,7 +329,25 @@ describe('PrReviewCommentComposer provider arm (s6)', () => { await flushMicrotasks(); expect(createCommentMocks.mutateAsync).toHaveBeenCalledWith({ - body: 'src/a.ts:L10\n\nhello', + 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 }, }); }); 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 31bac6df50..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 @@ -35,10 +35,7 @@ import { useFencedDraftLoad } from '@/lib/persist/use-draft-load'; 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 { - formatPendingCommentBody, - useCreateReviewCommentMutation, -} from '@/lib/pr-review/use-pr-review-mutations'; +import { useCreateReviewCommentMutation } from '@/lib/pr-review/use-pr-review-mutations'; import { type ProviderPrRef, providerPrRefKey } from '@/lib/pr-review/provider-pr-ref'; type CommentComposerMode = @@ -51,10 +48,10 @@ type PrReviewCommentComposerProps = Readonly<{ number: number; /** * The provider ref (s6). Present on the GitLab/Bitbucket surface: the - * comment posts through `providerReview.addComment` with the location - * anchored in the body, 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. + * 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; @@ -217,11 +214,20 @@ export function PrReviewCommentComposer(props: PrReviewCommentComposerProps) { return; } try { - // Provider arms post body-only: the inline position rides in the - // text, because the provider APIs have no comment position. + // 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: formatPendingCommentBody({ path, line, startLine, body }) } + ? { + body, + anchor: { + path, + side, + line, + ...(startLine !== undefined ? { startLine } : {}), + }, + } : { owner, repo, diff --git a/apps/mobile/src/components/pr-review/pr-review-submit.test.tsx b/apps/mobile/src/components/pr-review/pr-review-submit.test.tsx index d36c15ce72..db1c769d51 100644 --- a/apps/mobile/src/components/pr-review/pr-review-submit.test.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-submit.test.tsx @@ -27,6 +27,40 @@ vi.mock('@/lib/pr-review/use-pr-review-mutations', () => ({ isPending: false, error: submitMutationMock.error, }), + // Mirrors the real mapper (pure field routing, tested in + // use-pr-review-mutations.test.ts): anchored items ride the `comments` + // batch, an item without a side keeps the text-anchored body fallback. + buildProviderSubmitInput: ( + summary: string, + items: readonly { + path: string; + side?: 'LEFT' | 'RIGHT'; + line: number; + startLine?: number; + body: string; + }[] + ) => { + const comments: { + path: string; + side: 'LEFT' | 'RIGHT'; + line: number; + startLine?: number; + body: string; + }[] = []; + const folded: string[] = []; + for (const item of items) { + if (item.side === undefined) { + folded.push(`${item.path}:L${item.line}\n\n${item.body}`); + } else { + const { path, side, line, startLine, body } = item; + comments.push({ path, side, line, startLine, body }); + } + } + return { + body: [summary.trim(), ...folded].filter(part => part.length > 0).join('\n\n'), + comments, + }; + }, })); const footerPreferenceMock = vi.hoisted(() => ({ @@ -321,6 +355,96 @@ describe('PrReviewSubmit footer preference', () => { }); }); +// ── c3: provider arm submits anchored comments ─────────────────────── + +describe('PrReviewSubmit provider arm (c3)', () => { + const ITEM_FRESH_RANGE: PendingReviewItem = { + id: 'fresh-r', + path: 'src/r.ts', + side: 'LEFT', + line: 8, + startLine: 5, + body: 'R', + commitSha: 'head-1', + }; + + it('submits fresh pending items as a real anchored comments batch', async () => { + const renderer = mount(GITLAB_REF); + + act(() => { + addCommentFn?.(ITEM_FRESH_A); + addCommentFn?.(ITEM_FRESH_B); + addCommentFn?.(ITEM_STALE); + }); + act(() => { + submitOnPress(renderer)(); + }); + await flush(); + + // The tapped positions ride the `comments` batch (event + items, no + // summary typed); stale items are never sent and stay queued. + expect(submitMutationMock.mutateAsync).toHaveBeenCalledWith({ + event: 'comment', + comments: [ + { path: 'src/a.ts', side: 'RIGHT', line: 1, body: 'A' }, + { path: 'src/b.ts', side: 'RIGHT', line: 2, body: 'B' }, + ], + }); + expect(latestItems.map(item => item.id)).toEqual(['stale-c']); + }); + + it('carries a multi-line range into the comment item', async () => { + const renderer = mount(GITLAB_REF); + + act(() => { + addCommentFn?.(ITEM_FRESH_RANGE); + }); + act(() => { + submitOnPress(renderer, 'Submit review')(); + }); + await flush(); + + expect(submitMutationMock.mutateAsync).toHaveBeenCalledWith({ + event: 'comment', + comments: [{ path: 'src/r.ts', side: 'LEFT', line: 8, startLine: 5, body: 'R' }], + }); + }); + + it('posts the summary alone when nothing is queued (empty state, no comments key)', async () => { + footerPreferenceMock.prReviewFooter = true; + const renderer = mount(GITLAB_REF); + + act(() => { + submitOnPress(renderer, 'Submit review')(); + }); + await flush(); + + expect(submitMutationMock.mutateAsync).toHaveBeenCalledWith({ + event: 'comment', + body: '---\nReviewed via the [Kilo iOS app](https://apps.apple.com/app/id6761193135)', + }); + }); + + it('keeps the summary as the review body beside the anchored batch', async () => { + footerPreferenceMock.prReviewFooter = true; + const renderer = mount(GITLAB_REF); + + act(() => { + addCommentFn?.(ITEM_FRESH_A); + }); + act(() => { + submitOnPress(renderer, 'Submit review')(); + }); + await flush(); + + expect(submitMutationMock.mutateAsync).toHaveBeenCalledWith({ + event: 'comment', + body: '---\nReviewed via the [Kilo iOS app](https://apps.apple.com/app/id6761193135)', + comments: [{ path: 'src/a.ts', side: 'RIGHT', line: 1, body: 'A' }], + }); + }); +}); + // ── s6f: refused-submit wording ────────────────────────────────────── /** Every string the mounted tree rendered inside a Text element. */ diff --git a/apps/mobile/src/components/pr-review/pr-review-submit.tsx b/apps/mobile/src/components/pr-review/pr-review-submit.tsx index ccc554a260..123088cc14 100644 --- a/apps/mobile/src/components/pr-review/pr-review-submit.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-submit.tsx @@ -55,7 +55,7 @@ import { formatNumber } from '@/lib/format'; import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; import { mutationErrorDisplay } from '@/lib/pr-review/mutation-error-display'; import { - buildProviderSubmitBody, + buildProviderSubmitInput, type ProviderReviewEventOption, useSubmitReviewMutation, } from '@/lib/pr-review/use-pr-review-mutations'; @@ -159,9 +159,10 @@ type PrReviewSubmitProps = Readonly<{ eyebrow: string; /** * The provider ref (s6). Present on the GitLab/Bitbucket surface: the - * submit posts one `providerReview.submitReview` event with the pending - * comments folded into the body, and the pending-comment edit route is - * the ref's own. Absent on GitHub, which keeps the exact pre-s6 path. + * submit posts one `providerReview.submitReview` event whose fresh pending + * comments ride the real inline `comments` batch (c3), and the + * pending-comment edit route is the ref's own. Absent on GitHub, which + * keeps the exact pre-s6 path. */ prRef?: ProviderPrRef; /** @@ -291,13 +292,17 @@ export function PrReviewSubmit(props: PrReviewSubmitProps) { } try { const body = bodyRef.current.trim(); - // Provider arms post one event; the summary and every fresh pending - // comment fold into the body (the provider event has no batch). + // Provider arms post one event with a real inline batch (c3): every + // fresh pending comment rides `comments` anchored at its tapped diff + // position, and the summary stays the review body. An item without an + // anchor keeps the text-anchored body fallback. + const provider = buildProviderSubmitInput(body, fresh); await submitReview.mutateAsync( prRef ? { event: EVENT_TO_PROVIDER[event], - body: buildProviderSubmitBody(body, fresh), + ...(provider.body.length > 0 ? { body: provider.body } : {}), + ...(provider.comments.length > 0 ? { comments: provider.comments } : {}), } : buildSubmitReviewInput({ owner, diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 39f9edf0da..2aaf73a5e6 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -338,7 +338,9 @@ "banner": { "title": "Not available on this provider", "accessibility": "Not available on this provider: {{reason}}" - } + }, + "autoMergeUnavailable": "Auto-merge is not available", + "autoMergeUnavailableReason": "Bitbucket Cloud does not expose auto-merge in its API" }, "merge": { "merge": "Merge", diff --git a/apps/mobile/src/lib/pr-review/use-pr-review-mutations.test.ts b/apps/mobile/src/lib/pr-review/use-pr-review-mutations.test.ts index 7d77c13ff3..5a066b3fdd 100644 --- a/apps/mobile/src/lib/pr-review/use-pr-review-mutations.test.ts +++ b/apps/mobile/src/lib/pr-review/use-pr-review-mutations.test.ts @@ -17,6 +17,10 @@ // fingerprint pins below mirror the server's `gitlabFingerprintInput` / // `bitbucketFingerprintInput` exactly — a GitLab comment and a same-named // GitHub comment can never share a ledger key (identity rule 17). +// c3: the provider arms carry the REAL diff position — an `anchor` on +// addComment and a `comments` batch on submitReview — and the pinned +// fingerprints below mirror the server folding the anchor flat and the +// parsed batch into the ledger key. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -27,7 +31,7 @@ import { type ProviderPrRef, type ProviderPrTriple } from '@/lib/pr-review/provi import { useCreateReviewCommentMutation, useSubmitReviewMutation } from './use-pr-review-mutations'; const hoistedKeys = vi.hoisted(() => ({ - getKey: vi.fn(() => 'hoisted-op-key'), + getKey: vi.fn((_: string) => 'hoisted-op-key'), rotateKey: vi.fn(), })); @@ -330,6 +334,48 @@ describe('useCreateReviewCommentMutation (s6 gitlab arm)', () => { ); }); + it('sends the tapped diff position as the real anchor and folds it into the fingerprint (c3)', async () => { + providerAddCommentMutateMock.mockResolvedValueOnce({ done: true, replayed: false }); + useCreateReviewCommentMutation(GITLAB_REF); + + await lastCapturedOptions?.mutationFn?.({ + body: 'inline nit', + anchor: { path: 'src/a.ts', side: 'RIGHT', line: 12, startLine: 10 }, + }); + expect(providerAddCommentMutateMock).toHaveBeenCalledWith({ + ...GITLAB_IDENTITY, + body: 'inline nit', + anchor: { path: 'src/a.ts', side: 'RIGHT', line: 12, startLine: 10 }, + operationKey: 'hoisted-op-key', + }); + // Pinned bytes mirroring the server's gitlabFingerprintInput: the anchor + // folds FLAT into the s1 create_review_comment field order (body, path, + // line, side, startLine) — a retried anchored comment keeps its key. + expect(hoistedKeys.getKey).toHaveBeenCalledWith( + '{"resource":["gitlab","https://gl.example.com","group/sub/app",12],"body":"inline nit","path":"src/a.ts","line":12,"side":"RIGHT","startLine":10}' + ); + }); + + it('rotates the key when the same body moves to another line (changed intent)', async () => { + providerAddCommentMutateMock.mockResolvedValue({ done: true, replayed: false }); + useCreateReviewCommentMutation(GITLAB_REF); + + await lastCapturedOptions?.mutationFn?.({ + body: 'inline nit', + anchor: { path: 'src/a.ts', side: 'RIGHT', line: 12 }, + }); + const atLine12 = hoistedKeys.getKey.mock.calls[0]?.[0]; + await lastCapturedOptions?.mutationFn?.({ + body: 'inline nit', + anchor: { path: 'src/a.ts', side: 'RIGHT', line: 13 }, + }); + const atLine13 = hoistedKeys.getKey.mock.calls[1]?.[0]; + expect(atLine12).not.toBe(atLine13); + expect(atLine12).toBe( + '{"resource":["gitlab","https://gl.example.com","group/sub/app",12],"body":"inline nit","path":"src/a.ts","line":12,"side":"RIGHT"}' + ); + }); + it('keys a GitLab comment apart from the same GitHub comment and from the same MR on another instance', async () => { providerAddCommentMutateMock.mockResolvedValue({ done: true, replayed: false }); const onInstanceA = prIntentFingerprint('create_review_comment', { @@ -415,6 +461,25 @@ describe('useCreateReviewCommentMutation (s6 bitbucket arm)', () => { '{"resource":["bitbucket","acme","widgets",77],"body":"inline nit"}' ); }); + + it('sends the anchor through the seam with the workspace fingerprint (c3)', async () => { + providerAddCommentMutateMock.mockResolvedValueOnce({ done: true, replayed: false }); + useCreateReviewCommentMutation(BITBUCKET_REF); + + await lastCapturedOptions?.mutationFn?.({ + body: 'inline nit', + anchor: { path: 'src/a.ts', side: 'LEFT', line: 5 }, + }); + expect(providerAddCommentMutateMock).toHaveBeenCalledWith({ + ...BITBUCKET_IDENTITY, + body: 'inline nit', + anchor: { path: 'src/a.ts', side: 'LEFT', line: 5 }, + operationKey: 'hoisted-op-key', + }); + expect(hoistedKeys.getKey).toHaveBeenCalledWith( + '{"resource":["bitbucket","acme","widgets",77],"body":"inline nit","path":"src/a.ts","line":5,"side":"LEFT"}' + ); + }); }); describe('useSubmitReviewMutation (P1-A-08c wiring)', () => { @@ -550,6 +615,44 @@ describe('useSubmitReviewMutation (s6 provider arms)', () => { ); }); + it('gitlab: sends the anchored comments batch and folds it into the fingerprint (c3)', async () => { + scopeOverride = { ref: GITLAB_REF, organizationId: 'org-9' }; + providerSubmitReviewMutateMock.mockResolvedValueOnce({ done: true, replayed: false }); + useSubmitReviewMutation(GITLAB_REF); + + const comments = [ + { path: 'src/a.ts', side: 'RIGHT' as const, line: 10, body: 'first' }, + { path: 'src/b.ts', side: 'LEFT' as const, line: 2, startLine: 1, body: 'second' }, + ]; + await lastCapturedOptions?.mutationFn?.({ event: 'approve', body: 'LGTM', comments }); + expect(providerSubmitReviewMutateMock).toHaveBeenCalledWith({ + ...GITLAB_IDENTITY, + event: 'approve', + body: 'LGTM', + comments, + operationKey: 'hoisted-op-key', + }); + // Pinned bytes mirroring the server's submitReview fingerprint input: + // the batch serializes in the router's field order (path, side, line, + // startLine, body), so a retried batch keeps its ledger identity. + expect(hoistedKeys.getKey).toHaveBeenCalledWith( + '{"resource":["gitlab","https://gl.example.com","group/sub/app",12],"event":"approve","body":"LGTM","comments":[{"path":"src/a.ts","side":"RIGHT","line":10,"body":"first"},{"path":"src/b.ts","side":"LEFT","line":2,"startLine":1,"body":"second"}]}' + ); + }); + + it('gitlab: an empty comments batch is no batch — pre-c3 bytes unchanged (c3)', async () => { + scopeOverride = { ref: GITLAB_REF, organizationId: 'org-9' }; + providerSubmitReviewMutateMock.mockResolvedValueOnce({ done: true, replayed: false }); + useSubmitReviewMutation(GITLAB_REF); + + await lastCapturedOptions?.mutationFn?.({ event: 'approve', body: 'LGTM', comments: [] }); + const sent = providerSubmitReviewMutateMock.mock.calls[0]?.[0] as Record; + expect(sent).not.toHaveProperty('comments'); + expect(hoistedKeys.getKey).toHaveBeenCalledWith( + '{"resource":["gitlab","https://gl.example.com","group/sub/app",12],"event":"approve","body":"LGTM"}' + ); + }); + it('bitbucket: routes request-changes through the seam with the workspace fingerprint', async () => { scopeOverride = { ref: BITBUCKET_REF, organizationId: 'org-9' }; providerSubmitReviewMutateMock.mockResolvedValueOnce({ done: true, replayed: false }); @@ -621,7 +724,7 @@ describe('submit_review fingerprint (P1-A-08c changed-input)', () => { }); }); -describe('provider pending-comment body builders (s6)', () => { +describe('provider pending-comment body builders (s6, c3)', () => { it('formatPendingCommentBody anchors a single-line position like the pending list shows it', async () => { const { formatPendingCommentBody } = await import('./use-pr-review-mutations'); expect(formatPendingCommentBody({ path: 'src/a.ts', line: 10, body: 'note' })).toBe( @@ -631,18 +734,36 @@ describe('provider pending-comment body builders (s6)', () => { formatPendingCommentBody({ path: 'src/a.ts', line: 12, startLine: 10, body: 'range' }) ).toBe('src/a.ts:L10–L12\n\nrange'); }); - it('buildProviderSubmitBody folds the summary and fresh comments into one body', async () => { - const { buildProviderSubmitBody } = await import('./use-pr-review-mutations'); + it('buildProviderSubmitInput sends anchored items as a real comments batch (c3)', async () => { + const { buildProviderSubmitInput } = await import('./use-pr-review-mutations'); + expect( + buildProviderSubmitInput('Looks good overall.', [ + { path: 'src/a.ts', side: 'RIGHT' as const, line: 10, body: 'first' }, + { path: 'src/b.ts', side: 'LEFT' as const, line: 2, startLine: 1, body: 'second' }, + ]) + ).toEqual({ + body: 'Looks good overall.', + comments: [ + { path: 'src/a.ts', side: 'RIGHT', line: 10, body: 'first' }, + { path: 'src/b.ts', side: 'LEFT', line: 2, startLine: 1, body: 'second' }, + ], + }); + }); + it('buildProviderSubmitInput keeps the text-anchored body for items without an anchor (c3)', async () => { + const { buildProviderSubmitInput } = await import('./use-pr-review-mutations'); expect( - buildProviderSubmitBody('Looks good overall.', [ - { path: 'src/a.ts', line: 10, body: 'first' }, - { path: 'src/b.ts', line: 2, startLine: 1, body: 'second' }, + buildProviderSubmitInput('Summary.', [ + { path: 'src/a.ts', line: 10, body: 'folded' }, + { path: 'src/b.ts', side: 'RIGHT' as const, line: 2, body: 'anchored' }, ]) - ).toBe('Looks good overall.\n\nsrc/a.ts:L10\n\nfirst\n\nsrc/b.ts:L1–L2\n\nsecond'); + ).toEqual({ + body: 'Summary.\n\nsrc/a.ts:L10\n\nfolded', + comments: [{ path: 'src/b.ts', side: 'RIGHT', line: 2, body: 'anchored' }], + }); }); - it('buildProviderSubmitBody drops empty parts: a summary-only approve posts exactly the summary', async () => { - const { buildProviderSubmitBody } = await import('./use-pr-review-mutations'); - expect(buildProviderSubmitBody(' LGTM ', [])).toBe('LGTM'); - expect(buildProviderSubmitBody('', [])).toBe(''); + it('buildProviderSubmitInput drops empty parts: a summary-only approve posts exactly the summary', async () => { + const { buildProviderSubmitInput } = await import('./use-pr-review-mutations'); + expect(buildProviderSubmitInput(' LGTM ', [])).toEqual({ body: 'LGTM', comments: [] }); + expect(buildProviderSubmitInput('', [])).toEqual({ body: '', comments: [] }); }); }); diff --git a/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts b/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts index 2fbedfd5b1..cae7de5ae1 100644 --- a/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts +++ b/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts @@ -16,12 +16,12 @@ // uses the LATEST head SHA (per the S3 contract) regardless of what // SHA each item was queued under; a per-item 422 surfaces inline. // -// Provider arms (s6): a GitLab MR or Bitbucket PR posts through -// `providerReview.addComment` / `providerReview.submitReview`. Those -// procedures carry NO inline position and NO comment batch — the -// provider APIs have none — so the composer anchors the position in -// the body text and the submit sheet folds the queued comments into -// the review summary (see `formatPendingCommentBody`). The GitHub arms +// Provider arms (s6, anchored by c3): a GitLab MR or Bitbucket PR posts +// through `providerReview.addComment` / `providerReview.submitReview` with +// the REAL diff position (`anchor: { path, side, line, startLine? }`) and a +// real `comments` batch, so an inline comment lands on the line the user +// tapped. `formatPendingCommentBody` stays as the no-anchor fallback: a +// position-less body keeps the `path:L10–L20` text anchor. The GitHub arms // stay byte-identical: same procedures, same inputs, same fingerprints. // // P1-A-08c: both hooks hoist one operation key per intent, so retries of the @@ -181,15 +181,26 @@ type CreateReviewCommentInput = RouterInputs['githubPrReview']['createReviewComm export type SubmitReviewInput = RouterInputs['githubPrReview']['submitReview']; export type SubmitReviewComment = NonNullable[number]; -/** The provider arms accept a body only — no inline position, no batch. */ -type ProviderCommentBody = { body: string }; +/** + * The real diff position a provider comment anchors to (c3): the line the + * user tapped, with `startLine` marking the first line of a multi-line + * range ending at `line`. Absent, the comment is a top-level note. + */ +export type ProviderCommentAnchor = { + path: string; + side: 'LEFT' | 'RIGHT'; + line: number; + startLine?: number; +}; + +/** The provider arms accept a body plus the optional real diff anchor. */ +type ProviderCommentBody = { body: string; anchor?: ProviderCommentAnchor }; export type CreateReviewCommentVars = CreateReviewCommentInput | ProviderCommentBody; /** - * The body a provider comment carries: GitLab and Bitbucket have no inline - * comment position, so the composer (direct post) and the submit sheet - * (pending comments folded into the review summary) anchor the location in - * the text itself, in the same `path:L10–L20` format the pending list shows. + * The no-anchor fallback body: when the composer has no real diff position, + * the location rides in the text itself, in the same `path:L10–L20` format + * the pending list shows. */ export function formatPendingCommentBody(item: { path: string; @@ -204,24 +215,41 @@ export function formatPendingCommentBody(item: { return `${location}\n\n${item.body}`; } +/** One anchored item in a provider `submitReview` batch (c3). */ +export type ProviderSubmitReviewComment = ProviderCommentAnchor & { body: string }; + /** - * The body a provider `submitReview` carries: the summary, then every fresh - * pending comment anchored in the text (the provider event has no comment - * batch). Empty parts are dropped, so an approve with a summary alone posts - * exactly the summary and an approve with neither posts nothing. + * The provider `submitReview` intent the submit sheet builds: every pending + * item with a side rides the real `comments` batch; an item without one + * keeps the text-anchored body (the no-anchor fallback). Empty body parts + * are dropped, so an approve with a summary alone posts exactly the summary + * and an approve with neither posts nothing. Comment items keep the router's + * field order (path, side, line, startLine, body): the server hashes the + * parsed batch into the `submit_review` fingerprint, and a key drift rotates + * the dedupe identity. */ -export function buildProviderSubmitBody( +export function buildProviderSubmitInput( summary: string, items: readonly { path: string; + side?: 'LEFT' | 'RIGHT'; line: number; startLine?: number; body: string; }[] -): string { - return [summary.trim(), ...items.map(item => formatPendingCommentBody(item))] - .filter(part => part.length > 0) - .join('\n\n'); +) { + const comments: ProviderSubmitReviewComment[] = []; + const folded: string[] = []; + for (const item of items) { + if (item.side === undefined) { + folded.push(formatPendingCommentBody(item)); + } else { + const { path, side, line, startLine, body } = item; + comments.push({ path, side, line, ...(startLine !== undefined ? { startLine } : {}), body }); + } + } + const body = [summary.trim(), ...folded].filter(part => part.length > 0).join('\n\n'); + return { body, comments }; } /** The events the `providerReview.submitReview` input accepts. */ @@ -229,6 +257,7 @@ export type ProviderReviewEventOption = 'approve' | 'request_changes' | 'comment type ProviderSubmitReviewVars = { event: ProviderReviewEventOption; body?: string; + comments?: ProviderSubmitReviewComment[]; }; export type SubmitReviewVars = SubmitReviewInput | ProviderSubmitReviewVars; @@ -251,16 +280,26 @@ export function useCreateReviewCommentMutation(ref: ReviewWriteRef) { return result; } const vars = input as ProviderCommentBody; - const result = await trpcClient.providerReview.addComment.mutate({ + // The anchor rides the input AND the fingerprint: path/line/side/ + // startLine are s1 create_review_comment fields, so a retried + // anchored comment dedupes and a moved position starts a fresh + // intent. The anchor keys are exactly the fingerprint field names, + // so the spread folds them FLAT the way the server's + // gitlabFingerprintInput / bitbucketFingerprintInput does. Hoisted + // off the call so the extra fields compile against the router input + // type the client is typed against (provider-review-router.ts). + const addCommentInput = { ...providerWriteIdentity(scope), body: vars.body, + ...(vars.anchor ? { anchor: vars.anchor } : {}), operationKey: getKey( prIntentFingerprint( 'create_review_comment', - providerFingerprintInput(scope.ref, { body: vars.body }) + providerFingerprintInput(scope.ref, { body: vars.body, ...vars.anchor }) ) ), - }); + }; + const result = await trpcClient.providerReview.addComment.mutate(addCommentInput); rotateKey(); return result; } catch (error) { @@ -304,17 +343,26 @@ export function useSubmitReviewMutation(ref: ReviewWriteRef) { return result; } const vars = input as ProviderSubmitReviewVars; - const result = await trpcClient.providerReview.submitReview.mutate({ + // An empty batch is no batch: the key and the input omit `comments` + // together, so an event-only review keeps its pre-c3 bytes. The + // batch rides the fingerprint exactly as the server folds the parsed + // `comments` into the submit_review fingerprint. Hoisted off the + // call so the batch compiles against the router input type the + // client is typed against (provider-review-router.ts). + const comments = vars.comments?.length ? vars.comments : undefined; + const submitReviewInput = { ...providerWriteIdentity(scope), event: vars.event, ...(vars.body !== undefined && vars.body.length > 0 ? { body: vars.body } : {}), + ...(comments ? { comments } : {}), operationKey: getKey( prIntentFingerprint( 'submit_review', - providerFingerprintInput(scope.ref, { event: vars.event, body: vars.body }) + providerFingerprintInput(scope.ref, { event: vars.event, body: vars.body, comments }) ) ), - }); + }; + const result = await trpcClient.providerReview.submitReview.mutate(submitReviewInput); rotateKey(); return result; } catch (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 index 4a2ac6ea13..6d8c6ad0d5 100644 --- a/apps/web/src/lib/provider-review/bitbucket-write.test.ts +++ b/apps/web/src/lib/provider-review/bitbucket-write.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach, afterEach } from '@jest/globals'; +import { describe, expect, it, beforeEach, afterEach } from "@jest/globals"; import { addComment, BITBUCKET_AUTO_MERGE_UNSUPPORTED_REASON, @@ -10,46 +10,52 @@ import { replyToComment, resolveThread, submitReview, -} from './bitbucket-write'; -import { BitbucketReviewError } from './bitbucket-authorization'; +} 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/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/config.server', () => ({ - GIT_TOKEN_SERVICE_API_URL: 'https://token-service.example.com', -})); - -jest.mock('@/lib/tokens', () => ({ - generateInternalServiceToken: jest.fn(() => 'svc-mock-token'), +jest.mock("@/lib/tokens", () => ({ + generateInternalServiceToken: jest.fn(() => "svc-mock-token"), TOKEN_EXPIRY: { fiveMinutes: 300 }, })); -jest.mock('@/lib/utils.server', () => ({ +jest.mock("@/lib/utils.server", () => ({ logExceptInTest: () => {}, warnExceptInTest: () => {}, })); const ORG_OWNER = { - type: 'organization' as const, - organizationId: 'org_1', - userId: 'user_1', + type: "organization" as const, + organizationId: "org_1", + userId: "user_1", }; -const WORKSPACE = { uuid: '12345678-1234-1234-1234-123456789012', slug: 'acme' }; +const WORKSPACE = { + uuid: "12345678-1234-1234-1234-123456789012", + slug: "acme", +}; -const HEAD_SHA = 'abc123def4567890'; +const HEAD_SHA = "abc123def4567890"; const openPr = { id: 12, - state: 'OPEN', + state: "OPEN", source: { commit: { hash: HEAD_SHA } }, }; @@ -58,59 +64,66 @@ let fetchMock: jest.Mock; function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, - headers: { 'content-type': 'application/json' }, + headers: { "content-type": "application/json" }, }); } /** Await a rejection and return it typed, without a success-branch union. */ -async function captureRejection(promise: Promise): Promise { +async function captureRejection( + promise: Promise, +): Promise { try { await promise; } catch (reason) { return reason as BitbucketReviewError; } - throw new Error('Expected the call to reject.'); + throw new Error("Expected the call to reject."); } beforeEach(() => { jest.clearAllMocks(); mockGetBitbucketWorkspaceAccessTokenStatus.mockResolvedValue({ - status: 'connected', - integrationId: 'intg_1', - workspace: { ...WORKSPACE, displayName: 'Acme' }, + status: "connected", + integrationId: "intg_1", + workspace: { ...WORKSPACE, displayName: "Acme" }, }); mockReadCachedRepositories.mockResolvedValue({ - status: 'available', + status: "available", repositories: [ { - id: '87654321-4321-4321-4321-210987654321', + id: "87654321-4321-4321-4321-210987654321", workspaceUuid: WORKSPACE.uuid, - name: 'repo', - fullName: 'acme/repo', + name: "repo", + fullName: "acme/repo", private: true, - defaultBranch: 'main', + defaultBranch: "main", }, ], - syncedAt: '2026-09-06T00:00:00.000Z', + 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 (url.toString().includes("token-service.example.com")) { + return jsonResponse({ + status: "available", + token: "at-mock-token", + workspace: WORKSPACE, + }); } - if (parsed.pathname === '/2.0/user') { - return jsonResponse({ uuid: '{current-user-uuid}' }); + if (parsed.pathname === "/2.0/user") { + return jsonResponse({ uuid: "{current-user-uuid}" }); } - if (parsed.pathname.endsWith('/pullrequests/12')) return jsonResponse(openPr); - if (parsed.pathname.endsWith('/tasks')) { + 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')) { + 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 }); @@ -126,216 +139,446 @@ afterEach(() => { function bitbucketCalls(): Array<{ url: URL; init: Record }> { return fetchMock.mock.calls - .map(call => ({ + .map((call) => ({ url: new URL(String(call[0])), init: (call[1] ?? {}) as Record, })) - .filter(call => call.url.hostname === 'api.bitbucket.org'); + .filter((call) => call.url.hostname === "api.bitbucket.org"); } -describe('addComment', () => { - it('posts the raw content to the pull request comments collection', async () => { +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', + workspace: "acme", + repoSlug: "repo", prId: 12, - body: 'A review comment', + 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' } }); + 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 startLine range spans from the first line to the anchor 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"); + expect(JSON.parse(String(post?.init.body))).toEqual({ + content: { raw: "This block" }, + inline: { path: "src/deploy.ts", from: 10, to: 20 }, + }); }); - it('maps a provider 403 to non-retryable forbidden', async () => { + 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 }); + if (full.includes("token-service.example.com")) { + return jsonResponse({ + status: "available", + token: "at-mock-token", + workspace: WORKSPACE, + }); } - if (new URL(full).pathname === '/2.0/user') return jsonResponse({ uuid: '{u}' }); + 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, + }); + } + if (new URL(full).pathname === "/2.0/user") + return jsonResponse({ uuid: "{u}" }); return new Response(null, { status: 403 }); }); const error = await captureRejection( addComment({ owner: ORG_OWNER, - workspace: 'acme', - repoSlug: 'repo', + workspace: "acme", + repoSlug: "repo", prId: 12, - body: 'Nope', - }) + body: "Nope", + }), ); expect(error).toBeInstanceOf(BitbucketReviewError); - expect(error.kind).toBe('forbidden'); + expect(error.kind).toBe("forbidden"); expect(error.retryable).toBe(false); }); }); -describe('replyToComment', () => { - it('posts a reply carrying the parent comment id', async () => { +describe("replyToComment", () => { + it("posts a reply carrying the parent comment id", async () => { const result = await replyToComment({ owner: ORG_OWNER, - workspace: 'acme', - repoSlug: 'repo', + workspace: "acme", + repoSlug: "repo", prId: 12, - commentId: '101', - body: 'A reply', + 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'); + 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' }, + content: { raw: "A reply" }, parent: { id: 101 }, }); }); - it('refuses a non-numeric comment id as bad_request without any provider call', async () => { + 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', + workspace: "acme", + repoSlug: "repo", prId: 12, - commentId: 'not-a-number', - body: 'A reply', - }) + commentId: "not-a-number", + body: "A reply", + }), ); - expect(error.kind).toBe('bad_request'); + expect(error.kind).toBe("bad_request"); expect(bitbucketCalls()).toEqual([]); }); }); -describe('submitReview', () => { - it('maps approve to the participants state approved for the connected identity', async () => { +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', + workspace: "acme", + repoSlug: "repo", prId: 12, - event: 'approve', + event: "approve", }); expect(result).toEqual({ done: true, replayed: false }); const put = bitbucketCalls().find( - call => call.init.method === 'PUT' && call.url.pathname.includes('/participants/') + (call) => + call.init.method === "PUT" && + call.url.pathname.includes("/participants/"), ); expect(put?.url.pathname).toBe( - '/2.0/repositories/acme/repo/pullrequests/12/participants/%7Bcurrent-user-uuid%7D' + "/2.0/repositories/acme/repo/pullrequests/12/participants/%7Bcurrent-user-uuid%7D", ); - expect(JSON.parse(String(put?.init.body))).toEqual({ state: 'approved' }); + expect(JSON.parse(String(put?.init.body))).toEqual({ state: "approved" }); }); - it('maps request_changes to participants state changes_requested', async () => { + it("maps request_changes to participants state changes_requested", async () => { const result = await submitReview({ owner: ORG_OWNER, - workspace: 'acme', - repoSlug: 'repo', + workspace: "acme", + repoSlug: "repo", prId: 12, - event: 'request_changes', + event: "request_changes", }); expect(result).toEqual({ done: true, replayed: false }); const put = bitbucketCalls().find( - call => call.init.method === 'PUT' && call.url.pathname.includes('/participants/') + (call) => + call.init.method === "PUT" && + call.url.pathname.includes("/participants/"), ); - expect(JSON.parse(String(put?.init.body))).toEqual({ state: 'changes_requested' }); + 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 () => { + 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', + workspace: "acme", + repoSlug: "repo", prId: 12, - event: 'comment', - body: 'Read this first', + 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/') + (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' } }); + 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 () => { + 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', + workspace: "acme", + repoSlug: "repo", prId: 12, - event: 'comment', - }) + event: "comment", + }), ); - expect(error.kind).toBe('bad_request'); + 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/%7Bcurrent-user-uuid%7D", + "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 startLine range spans from the first line to the anchor line. + inline: { path: "b.ts", from: 4, to: 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 provider rejection surfaces the classified error and stops the batch", 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 === "/2.0/user") + return jsonResponse({ uuid: "{current-user-uuid}" }); + 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" }, + ], + }), + ); + + expect(error.kind).toBe("bad_request"); + expect(error.retryable).toBe(false); + // 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); + }); }); -describe('resolveThread', () => { - it('resolves the comment task when one exists', async () => { +describe("resolveThread", () => { + it("resolves the comment task when one exists", async () => { const result = await resolveThread({ owner: ORG_OWNER, - workspace: 'acme', - repoSlug: 'repo', + workspace: "acme", + repoSlug: "repo", prId: 12, - threadId: '101', + 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'); + 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 () => { + 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 }); + 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("/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', + workspace: "acme", + repoSlug: "repo", prId: 12, - threadId: '101', - }) + threadId: "101", + }), ); - expect(error.kind).toBe('bad_request'); + 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 () => { + 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 }); + 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/user') return jsonResponse({ uuid: '{u}' }); - if (parsed.pathname.endsWith('/comments/101')) return jsonResponse({ id: 101 }); - if (parsed.pathname.endsWith('/tasks')) { + if (parsed.pathname === "/2.0/user") return jsonResponse({ uuid: "{u}" }); + 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 } }], @@ -348,58 +591,77 @@ describe('resolveThread', () => { const error = await captureRejection( resolveThread({ owner: ORG_OWNER, - workspace: 'acme', - repoSlug: 'repo', + workspace: "acme", + repoSlug: "repo", prId: 12, - threadId: '101', - }) + threadId: "101", + }), ); - expect(error.kind).toBe('bad_request'); + 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); + expect(bitbucketCalls().some((call) => call.init.method === "PUT")).toBe( + false, + ); }); - it('refuses with the capability reason when the task collection is not exposed', async () => { + 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 }); + 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/user') return jsonResponse({ uuid: '{u}' }); - if (parsed.pathname.endsWith('/comments/101')) return jsonResponse({ id: 101 }); - if (parsed.pathname.endsWith('/tasks')) return new Response(null, { status: 404 }); + if (parsed.pathname === "/2.0/user") return jsonResponse({ uuid: "{u}" }); + 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', + workspace: "acme", + repoSlug: "repo", prId: 12, - threadId: '101', - }) + threadId: "101", + }), ); - expect(error.kind).toBe('bad_request'); + 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 () => { + 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 }); + 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/user') return jsonResponse({ uuid: '{u}' }); - if (parsed.pathname.endsWith('/comments/101')) return jsonResponse({ id: 101 }); - if (parsed.pathname.endsWith('/tasks')) { + if (parsed.pathname === "/2.0/user") return jsonResponse({ uuid: "{u}" }); + 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 } }], + values: [ + { + id: 7, + resolved_on: "2026-09-06T00:00:00.000Z", + comment: { id: 101 }, + }, + ], next: null, }); } @@ -408,26 +670,33 @@ describe('resolveThread', () => { const result = await resolveThread({ owner: ORG_OWNER, - workspace: 'acme', - repoSlug: 'repo', + workspace: "acme", + repoSlug: "repo", prId: 12, - threadId: '101', + threadId: "101", }); expect(result).toEqual({ done: true, replayed: true }); - expect(bitbucketCalls().some(call => call.init.method === 'PUT')).toBe(false); + 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 () => { + 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 }); + 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' + 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 } }], @@ -436,7 +705,7 @@ describe('resolveThread', () => { : 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', + next: "https://api.bitbucket.org/2.0/repositories/acme/repo/pullrequests/12/tasks?pagelen=100&page=2", }); } return jsonResponse({ pagelen: 50, values: [], next: null }); @@ -444,39 +713,54 @@ describe('resolveThread', () => { const result = await resolveThread({ owner: ORG_OWNER, - workspace: 'acme', - repoSlug: 'repo', + workspace: "acme", + repoSlug: "repo", prId: 12, - threadId: '101', + 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'); + 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); + expect( + bitbucketCalls().filter((call) => call.url.pathname.endsWith("/tasks")), + ).toHaveLength(2); }); - it('concludes replayed only after the whole task collection is exhausted', async () => { + 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 }); + 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' + 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 } }], + 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', + next: "https://api.bitbucket.org/2.0/repositories/acme/repo/pullrequests/12/tasks?pagelen=100&page=2", }); } return jsonResponse({ pagelen: 50, values: [], next: null }); @@ -484,103 +768,127 @@ describe('resolveThread', () => { const result = await resolveThread({ owner: ORG_OWNER, - workspace: 'acme', - repoSlug: 'repo', + workspace: "acme", + repoSlug: "repo", prId: 12, - threadId: '101', + threadId: "101", }); expect(result).toEqual({ done: true, replayed: true }); - expect(bitbucketCalls().some(call => call.init.method === 'PUT')).toBe(false); + 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 () => { + 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', + workspace: "acme", + repoSlug: "repo", prId: 12, - threadId: 'not-a-number', - }) + threadId: "not-a-number", + }), ); - expect(error.kind).toBe('not_found'); + 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 () => { +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', + workspace: "acme", + repoSlug: "repo", prId: 12, expectedHeadSha: HEAD_SHA, closeSourceBranch: true, - commitMessage: 'Merged in feature/retry', + 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'); + const post = bitbucketCalls().find((call) => call.init.method === "POST"); + expect(post?.url.pathname).toBe( + "/2.0/repositories/acme/repo/pullrequests/12/merge", + ); expect(JSON.parse(String(post?.init.body))).toEqual({ close_source_branch: true, - commit_message: 'Merged in feature/retry', + commit_message: "Merged in feature/retry", }); }); - it('refuses a stale revision with the exact reason and never merges', async () => { + 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', + workspace: "acme", + repoSlug: "repo", prId: 12, - expectedHeadSha: 'stale-sha', - }) + expectedHeadSha: "stale-sha", + }), ); - expect(error.kind).toBe('stale_head'); + 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); + expect( + bitbucketCalls().some((call) => call.url.pathname.endsWith("/merge")), + ).toBe(false); }); - it('reports replayed when the PR is already merged', async () => { + 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 }); + 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 } } }); + 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', + 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); + expect(bitbucketCalls().some((call) => call.init.method === "POST")).toBe( + false, + ); }); - it('refuses a closed PR with a non-retryable bad_request', async () => { + 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 }); + 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 } } }); + if (parsed.pathname.endsWith("/pullrequests/12")) { + return jsonResponse({ + id: 12, + state: "DECLINED", + source: { commit: { hash: HEAD_SHA } }, + }); } return jsonResponse({ pagelen: 50, values: [], next: null }); }); @@ -588,47 +896,48 @@ describe('mergePullRequest', () => { const error = await captureRejection( mergePullRequest({ owner: ORG_OWNER, - workspace: 'acme', - repoSlug: 'repo', + workspace: "acme", + repoSlug: "repo", prId: 12, expectedHeadSha: HEAD_SHA, - }) + }), ); - expect(error.kind).toBe('bad_request'); + 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', () => { +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', + reason: "Bitbucket Cloud does not expose auto-merge in its API", }); }); - it('reactions are unsupported with the provider reason', () => { + 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', + reason: + "Bitbucket Cloud does not expose reactions on pull request comments", }); }); - it('review events include request_changes', () => { + it("review events include request_changes", () => { expect(BITBUCKET_PR_REVIEW_CAPABILITIES.reviewEvents).toEqual([ - 'approve', - 'request_changes', - 'comment', + "approve", + "request_changes", + "comment", ]); }); - it('the exported reasons match the shared capability copy', () => { + it("the exported reasons match the shared capability copy", () => { expect(BITBUCKET_AUTO_MERGE_UNSUPPORTED_REASON).toBe( - BITBUCKET_PR_REVIEW_CAPABILITIES.autoMerge.reason + BITBUCKET_PR_REVIEW_CAPABILITIES.autoMerge.reason, ); expect(BITBUCKET_REACTIONS_UNSUPPORTED_REASON).toBe( - BITBUCKET_PR_REVIEW_CAPABILITIES.reactions.reason + 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 index 320620939b..33387d3c90 100644 --- a/apps/web/src/lib/provider-review/bitbucket-write.ts +++ b/apps/web/src/lib/provider-review/bitbucket-write.ts @@ -9,23 +9,27 @@ * 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 "server-only"; -import { z } from 'zod'; +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'; +} 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'; +} from "./bitbucket-authorization"; +import { + fetchPage, + requestBitbucketJson, + repositoryPathGuard, +} from "./bitbucket-read"; /** * The Bitbucket capability list for review surfaces. It reuses the shared @@ -51,14 +55,14 @@ export const BITBUCKET_REACTIONS_UNSUPPORTED_REASON = * 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'; + "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 pull request changed since it was loaded. Reload the pull request and try again."; /** The PR a write acts on. */ export type BitbucketPrTarget = { @@ -81,7 +85,7 @@ const BitbucketCurrentUserSchema = z.object({ uuid: z.string().min(1) }); const BitbucketPullRequestWriteSchema = z.object({ id: z.number(), - state: z.enum(['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED']), + state: z.enum(["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]), source: z .object({ commit: z @@ -114,7 +118,9 @@ 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 { +async function targetAccess( + target: BitbucketPrTarget, +): Promise { return authorizeRepository(target.owner, target.workspace, target.repoSlug); } @@ -123,9 +129,11 @@ async function targetAccess(target: BitbucketPrTarget): Promise { +async function ownAccountId( + access: BitbucketRepositoryAccess, +): Promise { const user = BitbucketCurrentUserSchema.parse( - await requestBitbucketJson(access, '/2.0/user') + await requestBitbucketJson(access, "/2.0/user"), ); return user.uuid; } @@ -135,11 +143,13 @@ async function ownAccountId(access: BitbucketRepositoryAccess): Promise * line (`to`), LEFT the source line (`from`); a `startLine` range spans * `from: startLine` to `to: line`. */ -function buildInlinePosition(anchor: ProviderReviewInlineAnchor): Record { +function buildInlinePosition( + anchor: ProviderReviewInlineAnchor, +): Record { if (anchor.startLine !== undefined) { return { path: anchor.path, from: anchor.startLine, to: anchor.line }; } - return anchor.side === 'RIGHT' + return anchor.side === "RIGHT" ? { path: anchor.path, to: anchor.line } : { path: anchor.path, from: anchor.line }; } @@ -153,17 +163,23 @@ export async function addComment( target: BitbucketPrTarget & { body: string; anchor?: ProviderReviewInlineAnchor; - } & BitbucketMutationInput + } & 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) } : {}), + 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); @@ -172,18 +188,28 @@ export async function addComment( /** Reply inside an existing comment thread. */ export async function replyToComment( - target: BitbucketPrTarget & { commentId: string; body: string } & BitbucketMutationInput + 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.'); + 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 } }, - }); + 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); @@ -202,42 +228,53 @@ export async function replyToComment( */ export async function submitReview( target: BitbucketPrTarget & { - event: 'approve' | 'request_changes' | 'comment'; + event: "approve" | "request_changes" | "comment"; body?: string; comments?: ProviderReviewInlineComment[]; - } & BitbucketMutationInput + } & BitbucketMutationInput, ): Promise { - if (target.event === 'comment' && !target.body && !target.comments?.length) { - throw new BitbucketReviewError('bad_request', 'A comment review needs a body.'); + 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); try { for (const comment of target.comments ?? []) { - await requestBitbucketJson(access, `${prPath(access, target.prId)}/comments`, { - method: 'POST', - body: { - content: { raw: comment.body }, - inline: buildInlinePosition(comment), + await requestBitbucketJson( + access, + `${prPath(access, target.prId)}/comments`, + { + method: "POST", + body: { + content: { raw: comment.body }, + inline: buildInlinePosition(comment), + }, }, - }); + ); } const accountId = await ownAccountId(access); const state = - target.event === 'approve' - ? 'approved' - : target.event === 'request_changes' - ? 'changes_requested' + 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 } } + { method: "PUT", body: { state } }, ); if (target.body) { - await requestBitbucketJson(access, `${prPath(access, target.prId)}/comments`, { - method: 'POST', - body: { content: { raw: target.body } }, - }); + await requestBitbucketJson( + access, + `${prPath(access, target.prId)}/comments`, + { + method: "POST", + body: { content: { raw: target.body } }, + }, + ); } return { done: true, replayed: false }; } catch (error) { @@ -258,7 +295,7 @@ async function findCommentTask( access: BitbucketRepositoryAccess, prId: number, commentId: number, - predicate: (task: z.infer) => boolean + predicate: (task: z.infer) => boolean, ): Promise<{ task: z.infer | null; sawTaskForComment: boolean; @@ -269,14 +306,18 @@ async function findCommentTask( let cursor: string | undefined = undefined; let exhausted = true; try { - for (let pageIndex = 0; pageIndex < MAX_TASK_COLLECTION_PAGES; pageIndex++) { + 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 } + { pagelen: 100 }, ); for (const value of page.values) { const parsed = BitbucketTaskWriteSchema.safeParse(value); @@ -297,8 +338,11 @@ async function findCommentTask( cursor = page.nextCursor; } } catch (error) { - if (error instanceof BitbucketReviewError && error.kind === 'not_found') { - throw new BitbucketReviewError('bad_request', BITBUCKET_THREAD_RESOLUTION_UNSUPPORTED_REASON); + if (error instanceof BitbucketReviewError && error.kind === "not_found") { + throw new BitbucketReviewError( + "bad_request", + BITBUCKET_THREAD_RESOLUTION_UNSUPPORTED_REASON, + ); } throw error; } @@ -313,11 +357,14 @@ async function findCommentTask( * the explicit capability reason — never a silent fallback. */ export async function resolveThread( - target: BitbucketPrTarget & { threadId: string } & BitbucketMutationInput + 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.'); + throw new BitbucketReviewError( + "not_found", + "This discussion thread could not be found.", + ); } const access = await targetAccess(target); try { @@ -326,15 +373,15 @@ export async function resolveThread( BitbucketCommentWriteSchema.parse( await requestBitbucketJson( access, - `${prPath(access, target.prId)}/comments/${commentId}` - ) + `${prPath(access, target.prId)}/comments/${commentId}`, + ), ); const { task, sawTaskForComment, exhausted } = await findCommentTask( access, target.prId, commentId, - candidate => candidate.resolved_on == null + (candidate) => candidate.resolved_on == null, ); if (!task) { if (!exhausted) { @@ -342,8 +389,8 @@ export async function resolveThread( // 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.' + "retryable", + "The Bitbucket task list is too large to resolve this thread. Try again.", ); } if (sawTaskForComment) { @@ -353,12 +400,19 @@ export async function resolveThread( } // 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); + 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 }, - }); + 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); @@ -372,44 +426,54 @@ export async function resolveThread( * refused with the explicit capability reason. */ export async function unresolveThread( - target: BitbucketPrTarget & { threadId: string } & BitbucketMutationInput + 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.'); + 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}` - ) + `${prPath(access, target.prId)}/comments/${commentId}`, + ), ); const { task, sawTaskForComment, exhausted } = await findCommentTask( access, target.prId, commentId, - candidate => candidate.resolved_on != null + (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.' + "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); + 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 }, - }); + 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); @@ -423,11 +487,11 @@ export async function unresolveThread( */ function requireHeadShaFence( pr: z.infer, - expectedHeadSha: string + expectedHeadSha: string, ): void { - const currentHead = pr.source?.commit?.hash ?? ''; + const currentHead = pr.source?.commit?.hash ?? ""; if (currentHead !== expectedHeadSha) { - throw new BitbucketReviewError('stale_head', BITBUCKET_STALE_HEAD_REASON); + throw new BitbucketReviewError("stale_head", BITBUCKET_STALE_HEAD_REASON); } } @@ -441,26 +505,31 @@ export async function mergePullRequest( expectedHeadSha: string; closeSourceBranch?: boolean; commitMessage?: string; - } & BitbucketMutationInput + } & BitbucketMutationInput, ): Promise { const access = await targetAccess(target); try { const pr = BitbucketPullRequestWriteSchema.parse( - await requestBitbucketJson(access, prPath(access, target.prId)) + await requestBitbucketJson(access, prPath(access, target.prId)), ); - if (pr.state === 'MERGED') { + 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.'); + if (pr.state !== "OPEN") { + throw new BitbucketReviewError( + "bad_request", + "The pull request is closed.", + ); } await requestBitbucketJson(access, `${prPath(access, target.prId)}/merge`, { - method: 'POST', + method: "POST", body: { close_source_branch: target.closeSourceBranch ?? false, - ...(target.commitMessage ? { commit_message: target.commitMessage } : {}), + ...(target.commitMessage + ? { commit_message: target.commitMessage } + : {}), }, }); return { done: true, replayed: 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 index 1d72b67755..1fb96358fc 100644 --- a/apps/web/src/lib/provider-review/gitlab-write.test.ts +++ b/apps/web/src/lib/provider-review/gitlab-write.test.ts @@ -1,7 +1,7 @@ -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 { 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, @@ -16,81 +16,97 @@ import { resolveThread, submitReview, unresolveThread, -} from './gitlab-write'; +} 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', () => ({ +jest.mock("@/lib/integrations/db/platform-integrations", () => ({ getIntegrationForOwner: (owner: Owner, platform: string) => mockGetIntegrationForOwner(owner, platform), })); -jest.mock('@/lib/integrations/gitlab-service', () => ({ +jest.mock("@/lib/integrations/gitlab-service", () => ({ getValidGitLabToken: (integration: PlatformIntegration, actor: unknown) => mockGetValidGitLabToken(integration, actor), })); -jest.mock('@/lib/integrations/platforms/gitlab/adapter', () => ({ +jest.mock("@/lib/integrations/platforms/gitlab/adapter", () => ({ createMRNote: (...args: unknown[]) => mockCreateMRNote(...args), - fetchGitLabMergeRequest: (params: unknown) => mockFetchGitLabMergeRequest(params), + 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'); +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) })), + 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'; +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 { +async function captureRejection( + promise: Promise, +): Promise { try { await promise; } catch (reason) { return reason as GitLabReviewError; } - throw new Error('Expected the call to reject.'); + 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', + 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 }], + 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', + title: "Add nested deploy script", description: null, - state: 'opened', + state: "opened", draft: false, - source_branch: 'feature/deploy', - target_branch: 'main', + source_branch: "feature/deploy", + target_branch: "main", sha: headSha, - diff_refs: { base_sha: 'sha-base', head_sha: headSha, start_sha: 'sha-start' }, + 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' }, + author: { id: 1, username: "alice", name: "Alice" }, ...extra, }; } @@ -100,96 +116,230 @@ let fetchMock: jest.Mock; function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, - headers: { 'content-type': 'application/json' }, + 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 }; + return { + url: new URL(String(last[0])), + init: (last[1] ?? {}) as RequestInit, + }; } beforeEach(() => { jest.clearAllMocks(); mockGetIntegrationForOwner.mockResolvedValue(integrationRow); - mockGetValidGitLabToken.mockResolvedValue('glpat-mock-token'); + mockGetValidGitLabToken.mockResolvedValue("glpat-mock-token"); mockCreateMRNote.mockResolvedValue(undefined); - mockFetchGitLabMergeRequest.mockResolvedValue(openMrFixture('sha-head')); - fetchMock = jest.fn().mockResolvedValue(jsonResponse({ state: 'opened' })); + 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' }); +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', + "glpat-mock-token", PROJECT_PATH, 12, - 'Ship it', - INSTANCE_URL + "Ship it", + INSTANCE_URL, ); }); - it('replies inside a discussion thread', async () => { + 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', + 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(init.method).toBe("POST"); expect(url.pathname).toBe( - `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/discussions/disc-1/notes` + `/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(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 adds old_line beside new_line", 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; + expect(position.new_line).toBe(20); + expect(position.old_line).toBe(10); + }); + + 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(JSON.parse(String(init.body))).toEqual({ body: 'Fixed' }); + + 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' }); +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` + `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/approve`, ); - expect(init.method).toBe('POST'); + expect(init.method).toBe("POST"); expect(mockCreateMRNote).toHaveBeenCalledWith( - 'glpat-mock-token', + "glpat-mock-token", PROJECT_PATH, 12, - 'LGTM', - INSTANCE_URL + "LGTM", + INSTANCE_URL, ); }); - it('approve without a body posts no note', async () => { - await submitReview({ ...TARGET, event: 'approve' }); + 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' }); + 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 () => { + 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' }) + submitReview({ ...TARGET, event: "request_changes", body: "Nope" }), ); expect(error).toBeInstanceOf(GitLabReviewError); - expect(error.kind).toBe('bad_request'); + 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: @@ -197,27 +347,156 @@ describe('submitReview', () => { 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'); + 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 provider rejection surfaces the classified error and stops the batch", 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" }, + ], + }), + ); + + expect(error.kind).toBe("bad_request"); + expect(error.retryable).toBe(false); + // 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(); }); }); -describe('resolveThread / unresolveThread', () => { +describe("resolveThread / unresolveThread", () => { function discussionFixture(resolved: boolean) { return { - id: 'disc-1', + id: "disc-1", individual_note: false, notes: [ { id: 11, - body: 'Guard this', - author: { id: 1, username: 'alice', name: 'Alice' }, - created_at: '', - updated_at: '', + body: "Guard this", + author: { id: 1, username: "alice", name: "Alice" }, + created_at: "", + updated_at: "", system: false, noteable_id: 100, - noteable_type: 'MergeRequest', + noteable_type: "MergeRequest", noteable_iid: 12, resolvable: true, resolved, @@ -226,233 +505,250 @@ describe('resolveThread / unresolveThread', () => { }; } - it('PUTs the discussion resolved flag', async () => { + it("PUTs the discussion resolved flag", async () => { fetchMock .mockResolvedValueOnce(jsonResponse(discussionFixture(false))) .mockResolvedValueOnce(jsonResponse(discussionFixture(true))); - const result = await resolveThread({ ...TARGET, discussionId: 'disc-1' }); + 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(init.method).toBe("PUT"); expect(url.pathname).toBe( - `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/discussions/disc-1` + `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/discussions/disc-1`, ); - expect(url.searchParams.get('resolved')).toBe('true'); + expect(url.searchParams.get("resolved")).toBe("true"); }); - it('reports replayed without a write when the thread is already resolved', async () => { + 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' }); + 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 () => { + it("unresolveThread clears the flag", async () => { fetchMock .mockResolvedValueOnce(jsonResponse(discussionFixture(true))) .mockResolvedValueOnce(jsonResponse(discussionFixture(false))); - const result = await unresolveThread({ ...TARGET, discussionId: 'disc-1' }); + const result = await unresolveThread({ ...TARGET, discussionId: "disc-1" }); expect(result).toEqual({ done: true, replayed: false }); - expect(lastRequest().url.searchParams.get('resolved')).toBe('false'); + expect(lastRequest().url.searchParams.get("resolved")).toBe("false"); }); - it('refuses to resolve a non-resolvable discussion', async () => { + it("refuses to resolve a non-resolvable discussion", async () => { fetchMock.mockResolvedValue( jsonResponse({ - id: 'disc-9', + id: "disc-9", individual_note: true, notes: [ { id: 20, - body: 'note', - author: { id: 1, username: 'alice', name: 'Alice' }, - created_at: '', - updated_at: '', + body: "note", + author: { id: 1, username: "alice", name: "Alice" }, + created_at: "", + updated_at: "", system: false, noteable_id: 100, - noteable_type: 'MergeRequest', + noteable_type: "MergeRequest", noteable_iid: 12, resolvable: false, }, ], - }) + }), ); - await expect(resolveThread({ ...TARGET, discussionId: 'disc-9' })).rejects.toMatchObject({ - kind: 'bad_request', + 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 () => { +describe("mergePullRequest", () => { + it("re-fetches the MR, fences the head, and merges the exact revision", async () => { const result = await mergePullRequest({ ...TARGET, - expectedHeadSha: 'sha-head', + expectedHeadSha: "sha-head", squash: true, shouldRemoveSourceBranch: true, - operationKey: 'op-merge', + operationKey: "op-merge", }); expect(result).toEqual({ done: true, replayed: false }); expect(mockFetchGitLabMergeRequest).toHaveBeenCalled(); const { url, init } = lastRequest(); - expect(init.method).toBe('PUT'); + expect(init.method).toBe("PUT"); expect(url.pathname).toBe( - `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/merge` + `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/merge`, ); expect(JSON.parse(String(init.body))).toEqual({ - sha: 'sha-head', + 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')); + 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' }) + mergePullRequest({ ...TARGET, expectedHeadSha: "sha-head" }), ); expect(error).toBeInstanceOf(GitLabReviewError); - expect(error.kind).toBe('stale_head'); + 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')); + 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 () => { + it("reports replayed when the MR is already merged", async () => { mockFetchGitLabMergeRequest.mockResolvedValue({ - ...openMrFixture('sha-head'), - state: 'merged', + ...openMrFixture("sha-head"), + state: "merged", }); - const result = await mergePullRequest({ ...TARGET, expectedHeadSha: 'sha-head' }); + 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 () => { + it("refuses a closed MR with a non-retryable bad_request", async () => { mockFetchGitLabMergeRequest.mockResolvedValue({ - ...openMrFixture('sha-head'), - state: 'closed', + ...openMrFixture("sha-head"), + state: "closed", }); await expect( - mergePullRequest({ ...TARGET, expectedHeadSha: 'sha-head' }) - ).rejects.toMatchObject({ kind: 'bad_request', retryable: false }); + 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)); + 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' }) + mergePullRequest({ ...TARGET, expectedHeadSha: "sha-head" }), ); - expect(error.kind).toBe('stale_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.' + "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 () => { +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' } }) + openMrFixture("sha-head", { head_pipeline: { status: "running" } }), ); - const result = await enableAutoMerge({ ...TARGET, expectedHeadSha: 'sha-head' }); + const result = await enableAutoMerge({ + ...TARGET, + expectedHeadSha: "sha-head", + }); expect(result).toEqual({ done: true, replayed: false }); const { url, init } = lastRequest(); - expect(init.method).toBe('PUT'); + 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` + `/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', + sha: "sha-head", }); }); - it('reports replayed when auto-merge is already enabled', async () => { + it("reports replayed when auto-merge is already enabled", async () => { mockFetchGitLabMergeRequest.mockResolvedValue( - openMrFixture('sha-head', { merge_when_pipeline_succeeds: true }) + openMrFixture("sha-head", { merge_when_pipeline_succeeds: true }), ); - const result = await enableAutoMerge({ ...TARGET, expectedHeadSha: 'sha-head' }); + const result = await enableAutoMerge({ + ...TARGET, + expectedHeadSha: "sha-head", + }); expect(result).toEqual({ done: true, replayed: true }); expect(fetchMock).not.toHaveBeenCalled(); }); - it('refuses a stale head with the exact reason and never arms', async () => { + it("refuses a stale head with the exact reason and never arms", async () => { mockFetchGitLabMergeRequest.mockResolvedValue( - openMrFixture('sha-moved', { head_pipeline: { status: 'running' } }) + openMrFixture("sha-moved", { head_pipeline: { status: "running" } }), ); const error = await captureRejection( - enableAutoMerge({ ...TARGET, expectedHeadSha: 'sha-head' }) + enableAutoMerge({ ...TARGET, expectedHeadSha: "sha-head" }), ); - expect(error.kind).toBe('stale_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 () => { + it("refuses an MR with no pipeline instead of letting GitLab merge immediately", async () => { mockFetchGitLabMergeRequest.mockResolvedValue( - openMrFixture('sha-head', { head_pipeline: null }) + openMrFixture("sha-head", { head_pipeline: null }), ); const error = await captureRejection( - enableAutoMerge({ ...TARGET, expectedHeadSha: 'sha-head' }) + enableAutoMerge({ ...TARGET, expectedHeadSha: "sha-head" }), ); expect(error).toBeInstanceOf(GitLabReviewError); - expect(error.kind).toBe('bad_request'); + 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')); + const mergeCall = fetchMock.mock.calls.find((call) => + String(call[0]).endsWith("/merge"), + ); expect(mergeCall).toBeUndefined(); }); - it('refuses when the latest pipeline already finished', async () => { + it("refuses when the latest pipeline already finished", async () => { mockFetchGitLabMergeRequest.mockResolvedValue( - openMrFixture('sha-head', { head_pipeline: { status: 'success' } }) + openMrFixture("sha-head", { head_pipeline: { status: "success" } }), ); await expect( - enableAutoMerge({ ...TARGET, expectedHeadSha: 'sha-head' }) + enableAutoMerge({ ...TARGET, expectedHeadSha: "sha-head" }), ).rejects.toMatchObject({ - kind: 'bad_request', + 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 () => { +describe("disableAutoMerge", () => { + it("cancels through the dedicated cancel endpoint, not the update endpoint", async () => { mockFetchGitLabMergeRequest.mockResolvedValue( - openMrFixture('sha-head', { merge_when_pipeline_succeeds: true }) + openMrFixture("sha-head", { merge_when_pipeline_succeeds: true }), ); const result = await disableAutoMerge({ ...TARGET }); @@ -461,85 +757,98 @@ describe('disableAutoMerge', () => { 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(init.method).toBe("POST"); expect(url.pathname).toBe( - `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/cancel_merge_when_pipeline_succeeds` + `/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 () => { + 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 () => { + it("fences a stale head when the caller provides one", async () => { mockFetchGitLabMergeRequest.mockResolvedValue( - openMrFixture('sha-moved', { merge_when_pipeline_succeeds: true }) + openMrFixture("sha-moved", { merge_when_pipeline_succeeds: true }), ); await expect( - disableAutoMerge({ ...TARGET, expectedHeadSha: 'sha-head' }) - ).rejects.toMatchObject({ kind: 'stale_head' }); + disableAutoMerge({ ...TARGET, expectedHeadSha: "sha-head" }), + ).rejects.toMatchObject({ kind: "stale_head" }); expect(fetchMock).not.toHaveBeenCalled(); }); }); -describe('deleteBranch', () => { - it('deletes the project branch', async () => { +describe("deleteBranch", () => { + it("deletes the project branch", async () => { fetchMock.mockResolvedValue(new Response(null, { status: 204 })); - const result = await deleteBranch({ ...TARGET, branchName: 'feature/deploy' }); + 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(init.method).toBe("DELETE"); expect(url.pathname).toBe( - `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/repository/branches/${encodeURIComponent('feature/deploy')}` + `/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)); + 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' }); + 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 () => { +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) + jsonResponse( + { message: "403 Forbidden — token glpat-secret denied" }, + 403, + ), ); - const error = await captureRejection(submitReview({ ...TARGET, event: 'approve' })); + const error = await captureRejection( + submitReview({ ...TARGET, event: "approve" }), + ); - expect(error.kind).toBe('forbidden'); + 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'); + 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)); + 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 }); + 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')); + 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' }) + replyToDiscussion({ ...TARGET, discussionId: "d", body: "x" }), ); - expect(error.kind).toBe('retryable'); + 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 index 61575cdbd8..bd4024cc19 100644 --- a/apps/web/src/lib/provider-review/gitlab-write.ts +++ b/apps/web/src/lib/provider-review/gitlab-write.ts @@ -10,27 +10,27 @@ * effect. `operationKey` is accepted for that ledger; this layer performs no * ledger writes itself. */ -import 'server-only'; +import "server-only"; import type { ProviderReviewCapabilities, ProviderReviewInlineAnchor, ProviderReviewInlineComment, -} from '@kilocode/app-shared/provider-review'; +} from "@kilocode/app-shared/provider-review"; import { createMRNote, fetchGitLabMergeRequest, type GitLabDiscussion, type GitLabMergeRequest, -} from '@/lib/integrations/platforms/gitlab/adapter'; +} from "@/lib/integrations/platforms/gitlab/adapter"; import { authorizeProject, classifyGitLabError, GitLabReviewError, type GitLabProjectAccess, type GitLabReviewOwner, -} from './gitlab-authorization'; -import { requestGitLabJson } from './gitlab-read'; +} from "./gitlab-authorization"; +import { requestGitLabJson } from "./gitlab-read"; /** The MR a write acts on. `instanceHint` is display/matching only. */ export type GitLabMrTarget = { @@ -54,14 +54,14 @@ export type GitLabMutationResult = { * 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.'; + "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 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 @@ -70,7 +70,7 @@ export const GITLAB_STALE_HEAD_REASON = * 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.'; + "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 @@ -80,12 +80,12 @@ export const GITLAB_AUTO_MERGE_NO_PIPELINE_REASON = */ export const GITLAB_MR_REVIEW_CAPABILITIES: ProviderReviewCapabilities = { canComment: true, - reviewEvents: ['approve', 'comment'], + reviewEvents: ["approve", "comment"], canResolveThreads: true, canMerge: true, - autoMerge: { supported: true, reason: '' }, - reactions: { supported: true, reason: '' }, - reviewStatus: { supported: true, reason: '' }, + autoMerge: { supported: true, reason: "" }, + reactions: { supported: true, reason: "" }, + reviewStatus: { supported: true, reason: "" }, }; type GitLabMergeRequestDetail = GitLabMergeRequest & { @@ -100,25 +100,31 @@ type GitLabMergeRequestDetail = GitLabMergeRequest & { * immediately instead of waiting, so auto-merge cannot be armed on it. */ const GITLAB_ACTIVE_PIPELINE_STATUSES = new Set([ - 'created', - 'waiting_for_resources', - 'waiting', - 'pending', - 'running', - 'scheduled', - 'preparing', - 'completing', + "created", + "waiting_for_resources", + "waiting", + "pending", + "running", + "scheduled", + "preparing", + "completing", ]); function hasActivePipeline(mr: GitLabMergeRequestDetail): boolean { return ( - typeof mr.head_pipeline?.status === 'string' && + 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); +async function targetAccess( + target: GitLabMrTarget, +): Promise { + return authorizeProject( + target.owner, + target.projectPath, + target.instanceHint, + ); } function mrPath(access: GitLabProjectAccess, mrIid: number): string { @@ -134,7 +140,7 @@ type GitLabDiffRefs = { base_sha: string; head_sha: string; start_sha: string }; async function fetchMrDiffRefs( access: GitLabProjectAccess, - mrIid: number + mrIid: number, ): Promise { const mr = (await fetchGitLabMergeRequest({ accessToken: access.accessToken, @@ -145,8 +151,8 @@ async function fetchMrDiffRefs( 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.' + "bad_request", + "The merge request has no diff positions to anchor a comment to.", ); } return refs; @@ -159,17 +165,17 @@ async function fetchMrDiffRefs( */ function buildTextPosition( refs: GitLabDiffRefs, - anchor: ProviderReviewInlineAnchor + anchor: ProviderReviewInlineAnchor, ): Record { const position: Record = { - position_type: 'text', + 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') { + if (anchor.side === "RIGHT") { position.new_line = anchor.line; if (anchor.startLine !== undefined) position.old_line = anchor.startLine; } else { @@ -186,12 +192,12 @@ function buildTextPosition( async function createInlineDiscussions( access: GitLabProjectAccess, mrIid: number, - anchored: Array<{ anchor: ProviderReviewInlineAnchor; body: string }> + anchored: Array<{ anchor: ProviderReviewInlineAnchor; body: string }>, ): Promise { const refs = await fetchMrDiffRefs(access, mrIid); for (const item of anchored) { await requestGitLabJson(access, `${mrPath(access, mrIid)}/discussions`, { - method: 'POST', + method: "POST", body: { body: item.body, position: buildTextPosition(refs, item.anchor) }, }); } @@ -203,7 +209,10 @@ async function createInlineDiscussions( * a top-level project note, byte-identical to the previous behavior. */ export async function addComment( - target: GitLabMrTarget & { body: string; anchor?: ProviderReviewInlineAnchor } & GitLabMutationInput + target: GitLabMrTarget & { + body: string; + anchor?: ProviderReviewInlineAnchor; + } & GitLabMutationInput, ): Promise { const access = await targetAccess(target); try { @@ -217,7 +226,7 @@ export async function addComment( access.projectPath, target.mrIid, target.body, - access.instanceUrl + access.instanceUrl, ); } return { done: true, replayed: false }; @@ -228,14 +237,17 @@ export async function addComment( /** Reply inside an existing discussion thread. */ export async function replyToDiscussion( - target: GitLabMrTarget & { discussionId: string; body: string } & GitLabMutationInput + 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 } } + { method: "POST", body: { body: target.body } }, ); return { done: true, replayed: false }; } catch (error) { @@ -254,13 +266,16 @@ export async function replyToDiscussion( */ export async function submitReview( target: GitLabMrTarget & { - event: 'approve' | 'comment' | 'request_changes'; + event: "approve" | "comment" | "request_changes"; body?: string; comments?: ProviderReviewInlineComment[]; - } & GitLabMutationInput + } & GitLabMutationInput, ): Promise { - if (target.event === 'request_changes') { - throw new GitLabReviewError('bad_request', GITLAB_REQUEST_CHANGES_UNSUPPORTED_REASON); + if (target.event === "request_changes") { + throw new GitLabReviewError( + "bad_request", + GITLAB_REQUEST_CHANGES_UNSUPPORTED_REASON, + ); } const access = await targetAccess(target); try { @@ -268,20 +283,27 @@ export async function submitReview( await createInlineDiscussions( access, target.mrIid, - target.comments.map(comment => ({ anchor: comment, body: comment.body })) + target.comments.map((comment) => ({ + anchor: comment, + body: comment.body, + })), ); } - if (target.event === 'approve') { - await requestGitLabJson(access, `${mrPath(access, target.mrIid)}/approve`, { - method: 'POST', - }); + 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 + access.instanceUrl, ); } } else if (target.body) { @@ -290,10 +312,13 @@ export async function submitReview( access.projectPath, target.mrIid, target.body, - access.instanceUrl + access.instanceUrl, ); } else if (!target.comments?.length) { - throw new GitLabReviewError('bad_request', 'A comment review needs a body.'); + throw new GitLabReviewError( + "bad_request", + "A comment review needs a body.", + ); } return { done: true, replayed: false }; } catch (error) { @@ -305,13 +330,13 @@ export async function submitReview( async function fetchDiscussionResolvedState( access: GitLabProjectAccess, mrIid: number, - discussionId: string + discussionId: string, ): Promise<{ resolved: boolean; resolvable: boolean }> { const discussion = await requestGitLabJson( access, - `${mrPath(access, mrIid)}/discussions/${encodeURIComponent(discussionId)}` + `${mrPath(access, mrIid)}/discussions/${encodeURIComponent(discussionId)}`, ); - const resolvableNote = discussion?.notes?.find(note => note.resolvable); + const resolvableNote = discussion?.notes?.find((note) => note.resolvable); return { resolvable: Boolean(resolvableNote), resolved: resolvableNote?.resolved === true, @@ -320,13 +345,20 @@ async function fetchDiscussionResolvedState( async function setThreadResolved( target: GitLabMrTarget & { discussionId: string } & GitLabMutationInput, - resolved: boolean + resolved: boolean, ): Promise { const access = await targetAccess(target); try { - const state = await fetchDiscussionResolvedState(access, target.mrIid, target.discussionId); + const state = await fetchDiscussionResolvedState( + access, + target.mrIid, + target.discussionId, + ); if (!state.resolvable) { - throw new GitLabReviewError('bad_request', 'This discussion cannot be resolved on GitLab.'); + throw new GitLabReviewError( + "bad_request", + "This discussion cannot be resolved on GitLab.", + ); } if (state.resolved === resolved) { return { done: true, replayed: true }; @@ -334,7 +366,7 @@ async function setThreadResolved( await requestGitLabJson( access, `${mrPath(access, target.mrIid)}/discussions/${encodeURIComponent(target.discussionId)}`, - { method: 'PUT', query: { resolved } } + { method: "PUT", query: { resolved } }, ); return { done: true, replayed: false }; } catch (error) { @@ -344,14 +376,14 @@ async function setThreadResolved( /** Resolve a discussion thread (PUT discussions). */ export async function resolveThread( - target: GitLabMrTarget & { discussionId: string } & GitLabMutationInput + 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 + target: GitLabMrTarget & { discussionId: string } & GitLabMutationInput, ): Promise { return setThreadResolved(target, false); } @@ -361,10 +393,13 @@ export async function unresolveThread( * 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 { +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); + throw new GitLabReviewError("stale_head", GITLAB_STALE_HEAD_REASON); } } @@ -380,7 +415,7 @@ export async function mergePullRequest( shouldRemoveSourceBranch?: boolean; commitTitle?: string; commitMessage?: string; - } & GitLabMutationInput + } & GitLabMutationInput, ): Promise { const access = await targetAccess(target); try { @@ -390,24 +425,31 @@ export async function mergePullRequest( mrIid: target.mrIid, instanceUrl: access.instanceUrl, })) as GitLabMergeRequestDetail; - if (mr.state === 'merged') { + 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.'); + 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', + 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 } : {}), + ...(target.commitTitle + ? { merge_commit_title: target.commitTitle } + : {}), + ...(target.commitMessage + ? { merge_commit_message: target.commitMessage } + : {}), }, }); return { done: true, replayed: false }; @@ -426,7 +468,7 @@ export async function mergePullRequest( * immediately in that state. Already-armed reports `replayed`. */ export async function enableAutoMerge( - target: GitLabMrTarget & { expectedHeadSha: string } & GitLabMutationInput + target: GitLabMrTarget & { expectedHeadSha: string } & GitLabMutationInput, ): Promise { const access = await targetAccess(target); try { @@ -441,10 +483,13 @@ export async function enableAutoMerge( } requireHeadShaFence(mr, target.expectedHeadSha); if (!hasActivePipeline(mr)) { - throw new GitLabReviewError('bad_request', GITLAB_AUTO_MERGE_NO_PIPELINE_REASON); + throw new GitLabReviewError( + "bad_request", + GITLAB_AUTO_MERGE_NO_PIPELINE_REASON, + ); } await requestGitLabJson(access, `${mrPath(access, target.mrIid)}/merge`, { - method: 'PUT', + method: "PUT", body: { merge_when_pipeline_succeeds: true, sha: target.expectedHeadSha }, }); return { done: true, replayed: false }; @@ -462,7 +507,7 @@ export async function enableAutoMerge( * not required here. */ export async function disableAutoMerge( - target: GitLabMrTarget & { expectedHeadSha?: string } & GitLabMutationInput + target: GitLabMrTarget & { expectedHeadSha?: string } & GitLabMutationInput, ): Promise { const access = await targetAccess(target); try { @@ -481,7 +526,7 @@ export async function disableAutoMerge( await requestGitLabJson( access, `${mrPath(access, target.mrIid)}/cancel_merge_when_pipeline_succeeds`, - { method: 'POST' } + { method: "POST" }, ); return { done: true, replayed: false }; } catch (error) { @@ -494,18 +539,18 @@ export async function disableAutoMerge( * state already, so it reports `replayed` rather than an error. */ export async function deleteBranch( - target: GitLabMrTarget & { branchName: string } & GitLabMutationInput + 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' } + { method: "DELETE" }, ); return { done: true, replayed: false }; } catch (error) { - if (error instanceof GitLabReviewError && error.kind === 'not_found') { + if (error instanceof GitLabReviewError && error.kind === "not_found") { return { done: true, replayed: true }; } throw error; diff --git a/apps/web/src/routers/provider-review-router.test.ts b/apps/web/src/routers/provider-review-router.test.ts index a13ec61bd2..fde109b7ca 100644 --- a/apps/web/src/routers/provider-review-router.test.ts +++ b/apps/web/src/routers/provider-review-router.test.ts @@ -1,26 +1,29 @@ /** * @jest-environment node */ -import { describe, expect, it, beforeAll, beforeEach } from '@jest/globals'; +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 { 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'; +} 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'; +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 @@ -35,23 +38,26 @@ const mockSettleOperation = jest.fn(); const mockMarkReconcilePending = jest.fn(); const mockRecordOperationAcceptance = jest.fn(); -jest.mock('@kilocode/db/operation-ledger', () => ({ +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), + markReconcilePending: (...args: unknown[]) => + mockMarkReconcilePending(...args), + recordOperationAcceptance: (...args: unknown[]) => + mockRecordOperationAcceptance(...args), })); // The router passes `db` to the (mocked) ledger only. -jest.mock('@/lib/drizzle', () => ({ db: {} })); +jest.mock("@/lib/drizzle", () => ({ db: {} })); const mockEnsureOrganizationAccess = jest.fn(); -jest.mock('./organizations/utils', () => ({ - ensureOrganizationAccess: (...args: unknown[]) => mockEnsureOrganizationAccess(...args), +jest.mock("./organizations/utils", () => ({ + ensureOrganizationAccess: (...args: unknown[]) => + mockEnsureOrganizationAccess(...args), })); const mockAssertTermsAccepted = jest.fn(); -jest.mock('./github-pr-review-router', () => ({ +jest.mock("./github-pr-review-router", () => ({ assertTermsAccepted: (...args: unknown[]) => mockAssertTermsAccepted(...args), })); @@ -66,7 +72,7 @@ const gitlabRead = { listInbox: jest.fn(), getMergeState: jest.fn(), }; -jest.mock('@/lib/provider-review/gitlab-read', () => ({ +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), @@ -86,14 +92,15 @@ const bitbucketRead = { listInbox: jest.fn(), getMergeRestrictions: jest.fn(), }; -jest.mock('@/lib/provider-review/bitbucket-read', () => ({ +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), + getMergeRestrictions: (...a: unknown[]) => + bitbucketRead.getMergeRestrictions(...a), requestBitbucketJson: jest.fn(), fetchPage: jest.fn(), repositoryPathGuard: jest.fn(), @@ -111,8 +118,8 @@ const gitlabWrite = { enableAutoMerge: jest.fn(), disableAutoMerge: jest.fn(), }; -jest.mock('@/lib/provider-review/gitlab-write', () => ({ - ...jest.requireActual('@/lib/provider-review/gitlab-write'), +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), @@ -131,8 +138,8 @@ const bitbucketWrite = { unresolveThread: jest.fn(), mergePullRequest: jest.fn(), }; -jest.mock('@/lib/provider-review/bitbucket-write', () => ({ - ...jest.requireActual('@/lib/provider-review/bitbucket-write'), +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), @@ -144,24 +151,26 @@ jest.mock('@/lib/provider-review/bitbucket-write', () => ({ // ----- fixtures --------------------------------------------------------------- const gitlabBase = { - platform: 'gitlab' as const, - projectPath: 'group/sub/repo', + platform: "gitlab" as const, + projectPath: "group/sub/repo", mrIid: 7, }; const bitbucketBase = { - platform: 'bitbucket' as const, + platform: "bitbucket" as const, organizationId: ORG_ID, - workspace: 'acme', - repoSlug: 'widgets', + workspace: "acme", + repoSlug: "widgets", prId: 12, }; -function admittedRow(overrides: Partial = {}): OperationLedgerRow { +function admittedRow( + overrides: Partial = {}, +): OperationLedgerRow { return { - id: 'row-1', - intent: 'create_review_comment', - resource_key: 'resource-key-under-test', - status: 'admitted', + id: "row-1", + intent: "create_review_comment", + resource_key: "resource-key-under-test", + status: "admitted", canonical_result: null, ...overrides, } as OperationLedgerRow; @@ -173,18 +182,27 @@ function admittedRow(overrides: Partial = {}): OperationLedg * 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 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), + ref: { platform: "gitlab", projectPath: "group/sub/repo", mrIid: 7 }, + state: "open", + headSha: "a".repeat(40), ...overrides, }; } @@ -199,378 +217,775 @@ beforeAll(() => { beforeEach(() => { jest.clearAllMocks(); - mockEnsureOrganizationAccess.mockResolvedValue('member'); + 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', + admission: "admitted", row: admittedRow({ intent: args.intent, resource_key: args.resourceKey }), })); mockSettleOperation.mockResolvedValue({ settled: true, row: admittedRow() }); - mockMarkReconcilePending.mockResolvedValue(admittedRow({ status: 'reconcile_pending' })); + 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 }); + 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 () => { +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' }, + { instanceUrl: "https://evil.example" }, + { token: "glpat-secret" }, + { host: "evil.example" }, + { userId: "victim" }, ]) { - await expect(caller.getPullRequest({ ...gitlabBase, ...smuggled })).rejects.toMatchObject({ - code: 'BAD_REQUEST', + await expect( + caller.getPullRequest({ ...gitlabBase, ...smuggled }), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", }); } expect(gitlabRead.getMergeRequest).not.toHaveBeenCalled(); }); - it('requires organizationId on the Bitbucket arm', async () => { + it("requires organizationId on the Bitbucket arm", async () => { await expect( caller.getPullRequest({ - platform: 'bitbucket', - workspace: 'acme', - repoSlug: 'widgets', + platform: "bitbucket", + workspace: "acme", + repoSlug: "widgets", prId: 12, - }) - ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }), + ).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 }); + 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' }) + caller.listFiles({ ...gitlabBase, cursor: "c1", direction: "forward" }), ).resolves.toBeDefined(); expect(gitlabRead.listChangedFiles).toHaveBeenCalledWith( - { type: 'user', userId: USER_ID }, - 'group/sub/repo', + { type: "user", userId: USER_ID }, + "group/sub/repo", 7, - 'c1', - undefined + "c1", + undefined, ); }); }); // ----- identity is server-derived ---------------------------------------------- -describe('providerReviewRouter identity derivation', () => { - it('runs ensureOrganizationAccess before any provider call when an organizationId is present', async () => { +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.objectContaining({ + user: expect.objectContaining({ id: USER_ID }), + }), + ORG_ID, ); expect(gitlabRead.getMergeRequest).toHaveBeenCalledWith( - { type: 'organization', organizationId: ORG_ID, userId: USER_ID }, - 'group/sub/repo', + { type: "organization", organizationId: ORG_ID, userId: USER_ID }, + "group/sub/repo", 7, - undefined + undefined, ); }); - it('derives the personal owner from ctx.user, never from input', async () => { + 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', + { type: "user", userId: USER_ID }, + "group/sub/repo", 7, - undefined + undefined, ); }); - it('stops before any provider call when the organization guard rejects', async () => { + it("stops before any provider call when the organization guard rejects", async () => { mockEnsureOrganizationAccess.mockRejectedValueOnce( - new TRPCError({ code: 'FORBIDDEN', message: 'no access' }) + new TRPCError({ code: "FORBIDDEN", message: "no access" }), ); await expect( - caller.addComment({ ...bitbucketBase, body: 'hi', operationKey: 'key-1' }) - ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + 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 () => { + 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' }); + 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', + { type: "user", userId: USER_ID }, + "group/sub/repo", 7, - 'gitlab.example' + "gitlab.example", ); }); - it('never lets a page cursor steer which repository is read', async () => { - gitlabRead.listChangedFiles.mockResolvedValue({ items: [], nextCursor: null }); + 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'), + 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', + { type: "user", userId: USER_ID }, + "group/sub/repo", 7, expect.any(String), - undefined + 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' }); +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 }, + "create_review_comment", + { platform: "gitlab", projectPath: "group/sub/repo", mrIid: 7 }, { - platform: 'gitlab', - projectPath: 'group/sub/repo', + platform: "gitlab", + projectPath: "group/sub/repo", instanceHint: undefined, number: 7, - body: 'hello', - } + body: "hello", + }, ); expect(mockAdmitOperation).toHaveBeenCalledWith( {}, expect.objectContaining({ userId: USER_ID, - domain: 'pr', - intent: 'create_review_comment', - operationKey: 'key-1', - taxonomy: 'reconcile-first', + 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); + 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', () => { + 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' } + "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::'; + const githubStyle = "octocat/hello#1::"; expect(gitlabKey.startsWith(githubStyle)).toBe(false); expect( gitlabKey.startsWith( - providerPrRefKey({ platform: 'gitlab', projectPath: 'octocat/hello', mrIid: 1 }) - ) + providerPrRefKey({ + platform: "gitlab", + projectPath: "octocat/hello", + mrIid: 1, + }), + ), ).toBe(true); const bitbucketKey = providerLedgerResourceKey( - 'create_review_comment', - { platform: 'bitbucket', workspace: 'octocat', repoSlug: 'hello', prId: 1 }, + "create_review_comment", + { + platform: "bitbucket", + workspace: "octocat", + repoSlug: "hello", + prId: 1, + }, { - platform: 'bitbucket', - workspace: 'octocat', - repoSlug: 'hello', + platform: "bitbucket", + workspace: "octocat", + repoSlug: "hello", number: 1, - body: 'same text', - } + 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' }); + 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', + 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'); + 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', + 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', + 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' }) + 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 () => { + it("refuses a key reused for a different intent with no effect and no replay", async () => { mockAdmitOperation.mockResolvedValueOnce({ - admission: 'admitted', - row: admittedRow({ intent: 'merge' }), + 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' }); + 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'); + 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' }); + 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 () => { + it("runs the UGC terms gate before admission", async () => { mockAssertTermsAccepted.mockRejectedValueOnce( - new TRPCError({ code: 'PRECONDITION_FAILED', message: 'terms_required' }) + 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' }); + 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 () => { + 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.') + new GitLabReviewError( + "retryable", + "Could not reach GitLab. Please try again.", + ), ); await expect( - caller.addComment({ ...gitlabBase, body: 'hello', operationKey: 'key-1' }) + caller.addComment({ + ...gitlabBase, + body: "hello", + operationKey: "key-1", + }), ).rejects.toMatchObject({ - code: 'CONFLICT', + code: "CONFLICT", message: "Couldn't confirm — check the merge request before retrying.", }); expect(mockMarkReconcilePending).toHaveBeenCalledWith( {}, - expect.objectContaining({ rowId: 'row-1' }) + 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' }); + 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 () => { +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) + 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 }); + 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.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' })); + 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', - }) + 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', + { type: "user", userId: USER_ID }, + "group/sub/repo", 7, - undefined + undefined, ); expect(gitlabWrite.mergePullRequest).not.toHaveBeenCalled(); expect(mockSettleOperation).toHaveBeenCalledWith( {}, expect.objectContaining({ - status: 'completed', + 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) })); + 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 }); + 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', + status: "failed", + outcomeCode: "head_moved", outboxEvent: expect.objectContaining({ - properties: expect.objectContaining({ reconcile_result: 'confirmed_absent' }), + 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')); + 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' }); + expectedHeadSha: "a".repeat(40), + operationKey: "key-merge", + }), + ).rejects.toMatchObject({ code: "CONFLICT" }); expect(mockMarkReconcilePending).toHaveBeenCalled(); expect(mockSettleOperation).not.toHaveBeenCalled(); }); @@ -578,17 +993,19 @@ describe('providerReviewRouter merge head fence', () => { // ----- 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 +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", ); - expect(GITLAB_MR_REVIEW_CAPABILITIES.reviewEvents).not.toContain('request_changes'); }); - it('answers Bitbucket with the shared capability list carrying the auto-merge reason', async () => { + it("answers Bitbucket with the shared capability list carrying the auto-merge reason", async () => { await expect( - caller.getCapabilities({ platform: 'bitbucket', organizationId: ORG_ID }) + caller.getCapabilities({ platform: "bitbucket", organizationId: ORG_ID }), ).resolves.toEqual(BITBUCKET_PR_REVIEW_CAPABILITIES); expect(BITBUCKET_PR_REVIEW_CAPABILITIES.autoMerge).toMatchObject({ supported: false, @@ -596,13 +1013,13 @@ describe('providerReviewRouter capabilities', () => { }); }); - it('returns the capability reason for Bitbucket auto-merge without a ledger row', async () => { + 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', - }) + expectedHeadSha: "a".repeat(40), + operationKey: "key-am", + }), ).resolves.toEqual({ supported: false, reason: BITBUCKET_AUTO_MERGE_UNSUPPORTED_REASON, @@ -611,28 +1028,35 @@ describe('providerReviewRouter capabilities', () => { }); expect(mockAdmitOperation).not.toHaveBeenCalled(); expect(mockEnsureOrganizationAccess).toHaveBeenCalled(); - await expect(caller.disableAutoMerge({ ...bitbucketBase })).resolves.toMatchObject({ + 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 () => { + 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 }); + 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' }) + expect.objectContaining({ intent: "enable_auto_merge" }), ); - await caller.disableAutoMerge({ ...gitlabBase, operationKey: 'key-dam' }); + await caller.disableAutoMerge({ ...gitlabBase, operationKey: "key-dam" }); expect(mockAdmitOperation).toHaveBeenCalledWith( {}, - expect.objectContaining({ intent: 'disable_auto_merge' }) + 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 index 6bb5c6562a..df1e663960 100644 --- a/apps/web/src/routers/provider-review-router.ts +++ b/apps/web/src/routers/provider-review-router.ts @@ -16,36 +16,43 @@ * 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 "server-only"; -import * as z from 'zod'; -import { createHash } from 'node:crypto'; -import { TRPCError } from '@trpc/server'; +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 { + 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'; +} 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'; +} 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, @@ -56,8 +63,8 @@ import { resolveThread as gitlabResolveThread, submitReview as gitlabSubmitReview, unresolveThread as gitlabUnresolveThread, -} from '@/lib/provider-review/gitlab-write'; -import * as bitbucketRead from '@/lib/provider-review/bitbucket-read'; +} 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, @@ -67,9 +74,9 @@ import { 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'; +} 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 ---------------------------------------------------------- @@ -84,7 +91,7 @@ const bitbucketSlugRegex = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; // 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 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 @@ -98,27 +105,33 @@ const operationKeySchema = z.string().min(1).max(128).optional(); // 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']), + 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'], -} as const; +}; const providerInlineAnchorInput = z .object(inlineAnchorShape) .strict() - .refine(value => value.startLine === undefined || value.startLine <= value.line, startLineOrderIssue); + .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); + .refine( + (value) => value.startLine === undefined || value.startLine <= value.line, + startLineOrderIssue, + ); const gitlabIdentityShape = { - platform: z.literal('gitlab'), + platform: z.literal("gitlab"), organizationId: z.uuid().optional(), projectPath: z.string().regex(gitlabProjectPathRegex).max(1024), mrIid: z.number().int().positive(), @@ -128,7 +141,7 @@ const gitlabIdentityShape = { }; const bitbucketIdentityShape = { - platform: z.literal('bitbucket'), + platform: z.literal("bitbucket"), // Bitbucket Cloud is organization-context only: the id is required and the // org guard always runs. organizationId: z.uuid(), @@ -139,27 +152,32 @@ const bitbucketIdentityShape = { /** One provider-discriminated PR/MR ref input, `.strict()` on both arms. */ function providerRefInput(extra: T) { - return z.discriminatedUnion('platform', [ + 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', [ +const providerIdentityInput = z.discriminatedUnion("platform", [ z .object({ - platform: z.literal('gitlab'), + 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(), + z + .object({ platform: z.literal("bitbucket"), organizationId: z.uuid() }) + .strict(), ]); const GetPullRequestInput = providerRefInput({}); -const ListFilesInput = providerRefInput({ cursor: pageCursor, direction: infiniteQueryDirection }); +const ListFilesInput = providerRefInput({ + cursor: pageCursor, + direction: infiniteQueryDirection, +}); const ListDiscussionsInput = providerRefInput({ cursor: pageCursor, @@ -168,10 +186,10 @@ const ListDiscussionsInput = providerRefInput({ const ListChecksInput = providerRefInput({}); -const ListInboxInput = z.discriminatedUnion('platform', [ +const ListInboxInput = z.discriminatedUnion("platform", [ z .object({ - platform: z.literal('gitlab'), + platform: z.literal("gitlab"), organizationId: z.uuid().optional(), instanceHint: z.string().min(1).max(2048).optional(), cursor: pageCursor, @@ -180,7 +198,7 @@ const ListInboxInput = z.discriminatedUnion('platform', [ .strict(), z .object({ - platform: z.literal('bitbucket'), + platform: z.literal("bitbucket"), organizationId: z.uuid(), cursor: pageCursor, direction: infiniteQueryDirection, @@ -210,7 +228,7 @@ const AddCommentInput = providerRefInput({ operationKey: operationKeySchema, }); -const ReplyToCommentInput = z.discriminatedUnion('platform', [ +const ReplyToCommentInput = z.discriminatedUnion("platform", [ // GitLab replies land inside a discussion; the discussion id is the thread. z .object({ @@ -232,7 +250,7 @@ const ReplyToCommentInput = z.discriminatedUnion('platform', [ ]); const SubmitReviewInput = providerRefInput({ - event: z.enum(['approve', 'request_changes', 'comment']), + 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 @@ -241,7 +259,7 @@ const SubmitReviewInput = providerRefInput({ operationKey: operationKeySchema, }); -const ResolveThreadInput = z.discriminatedUnion('platform', [ +const ResolveThreadInput = z.discriminatedUnion("platform", [ z .object({ ...gitlabIdentityShape, @@ -258,7 +276,7 @@ const ResolveThreadInput = z.discriminatedUnion('platform', [ .strict(), ]); -const MergePullRequestInput = z.discriminatedUnion('platform', [ +const MergePullRequestInput = z.discriminatedUnion("platform", [ z .object({ ...gitlabIdentityShape, @@ -304,21 +322,29 @@ const DisableAutoMergeInput = providerRefInput({ */ async function gitlabOwner( ctx: TRPCContext, - input: { organizationId?: string } + input: { organizationId?: string }, ): Promise { if (input.organizationId) { await ensureOrganizationAccess(ctx, input.organizationId); - return { type: 'organization', organizationId: input.organizationId, userId: ctx.user.id }; + return { + type: "organization", + organizationId: input.organizationId, + userId: ctx.user.id, + }; } - return { type: 'user', userId: ctx.user.id }; + return { type: "user", userId: ctx.user.id }; } async function bitbucketOwner( ctx: TRPCContext, - input: { organizationId: string } + input: { organizationId: string }, ): Promise { await ensureOrganizationAccess(ctx, input.organizationId); - return { type: 'organization', organizationId: input.organizationId, userId: ctx.user.id }; + return { + type: "organization", + organizationId: input.organizationId, + userId: ctx.user.id, + }; } function providerRef(input: { @@ -330,16 +356,16 @@ function providerRef(input: { repoSlug?: string; prId?: number; }): ProviderPrRef { - if (input.platform === 'gitlab') { + if (input.platform === "gitlab") { return { - platform: 'gitlab', + platform: "gitlab", projectPath: String(input.projectPath), mrIid: Number(input.mrIid), instanceHint: input.instanceHint, }; } return { - platform: 'bitbucket', + platform: "bitbucket", workspace: String(input.workspace), repoSlug: String(input.repoSlug), prId: Number(input.prId), @@ -355,23 +381,26 @@ function providerRef(input: { */ function toProviderTrpcError(error: unknown): TRPCError { if (error instanceof TRPCError) return error; - if (error instanceof GitLabReviewError || error instanceof BitbucketReviewError) { + 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 }); + 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.', + code: "INTERNAL_SERVER_ERROR", + message: "The review request failed. Please try again.", }); } @@ -391,20 +420,21 @@ async function providerCall(work: () => Promise): Promise { // 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_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.'; +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 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.'; + "We could not record this action. Please try again later."; /** * The provider ledger resource identity: the s1 canonical ref key (platform @@ -416,11 +446,11 @@ const PROVIDER_LEDGER_PERSISTENCE_FAILED_MESSAGE = export function providerLedgerResourceKey( intent: PrLedgerIntent, ref: ProviderPrRef, - fingerprintInput: Record + fingerprintInput: Record, ): string { - const fingerprint = createHash('sha256') + const fingerprint = createHash("sha256") .update(prIntentFingerprint(intent, fingerprintInput)) - .digest('hex') + .digest("hex") .slice(0, 16); return `${providerPrRefKey(ref)}::${fingerprint}`; } @@ -432,10 +462,10 @@ export function providerLedgerResourceKey( */ function gitlabFingerprintInput( input: { projectPath: string; mrIid: number; instanceHint?: string }, - fields: Record + fields: Record, ): Record { return { - platform: 'gitlab', + platform: "gitlab", projectPath: input.projectPath, instanceHint: input.instanceHint, number: input.mrIid, @@ -445,10 +475,10 @@ function gitlabFingerprintInput( function bitbucketFingerprintInput( input: { workspace: string; repoSlug: string; prId: number }, - fields: Record + fields: Record, ): Record { return { - platform: 'bitbucket', + platform: "bitbucket", workspace: input.workspace, repoSlug: input.repoSlug, number: input.prId, @@ -458,7 +488,7 @@ function bitbucketFingerprintInput( function ambiguousProviderError(platform: ProviderPrPlatform): TRPCError { return new TRPCError({ - code: 'CONFLICT', + code: "CONFLICT", message: `Couldn't confirm — check the ${providerPrTerm(platform)} before retrying.`, }); } @@ -468,12 +498,14 @@ function ambiguousProviderError(platform: ProviderPrPlatform): TRPCError { * 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 { +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)}` + `Failed to write provider PR operation ledger row: ${error instanceof Error ? error.message : String(error)}`, ); } } @@ -482,20 +514,22 @@ async function bestEffortLedgerWrite(work: () => Promise): Promise, - reconcileResult?: 'confirmed_completed' + reconcileResult?: "confirmed_completed", ): Promise { try { await settleOperation(db, { rowId: row.id, - status: 'completed', - outcomeCode: 'ok', + status: "completed", + outcomeCode: "ok", canonicalResult, outboxEvent: providerSettledOutboxEvent({ distinctId: base.distinctId, intent: base.intent, - outcome: 'completed', + outcome: "completed", reconcileResult, startedAt: base.startedAt, }), @@ -543,13 +577,17 @@ async function settleCompletedProviderRow( // 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 }) + 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)}` + `Failed to settle completed provider PR operation ledger row: ${error instanceof Error ? error.message : String(error)}`, ); throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', + code: "INTERNAL_SERVER_ERROR", message: PROVIDER_LEDGER_SETTLE_FAILED_MESSAGE, cause: error, }); @@ -561,21 +599,21 @@ async function settleFailedProviderRow( base: ProviderLedgerBase, row: OperationLedgerRow, outcomeCode: string, - reconcileResult?: 'confirmed_absent' + reconcileResult?: "confirmed_absent", ): Promise { await bestEffortLedgerWrite(() => settleOperation(db, { rowId: row.id, - status: 'failed', + status: "failed", outcomeCode, outboxEvent: providerSettledOutboxEvent({ distinctId: base.distinctId, intent: base.intent, - outcome: 'failed', + outcome: "failed", reconcileResult, startedAt: base.startedAt, }), - }) + }), ); } @@ -587,7 +625,7 @@ async function settleFailedProviderRow( */ async function failProviderRowAmbiguous( base: ProviderLedgerBase, - row: OperationLedgerRow + row: OperationLedgerRow, ): Promise { try { const updated = await markReconcilePending(db, { @@ -595,20 +633,22 @@ async function failProviderRowAmbiguous( outboxEvent: providerSettledOutboxEvent({ distinctId: base.distinctId, intent: base.intent, - outcome: 'ambiguous', - reconcileResult: 'unresolved', + outcome: "ambiguous", + reconcileResult: "unresolved", startedAt: base.startedAt, }), }); - if (!updated || updated.status !== 'reconcile_pending') { - throw new Error('markReconcilePending did not leave the row reconcile_pending'); + 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)}` + `Failed to mark provider PR operation ledger row reconcile-pending: ${error instanceof Error ? error.message : String(error)}`, ); throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', + code: "INTERNAL_SERVER_ERROR", message: PROVIDER_LEDGER_PERSISTENCE_FAILED_MESSAGE, cause: error, }); @@ -624,22 +664,25 @@ async function failProviderRowAmbiguous( * write path. */ function outcomeCodeFromFailure(error: unknown, trpcError: TRPCError): string { - if (error instanceof GitLabReviewError || error instanceof BitbucketReviewError) { - if (error.kind === 'stale_head') return 'head_moved'; + 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'; + 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'; + return "bad_request"; } } @@ -650,8 +693,8 @@ function outcomeCodeFromFailure(error: unknown, trpcError: TRPCError): string { * 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'; + if (error.code === "BAD_GATEWAY") return true; + return intent === "merge" && error.code === "NOT_FOUND"; } /** @@ -662,7 +705,7 @@ function isAmbiguousFailure(error: TRPCError, intent: PrLedgerIntent): boolean { async function executeProviderWrite>( base: ProviderLedgerBase, row: OperationLedgerRow, - write: () => Promise + write: () => Promise, ): Promise { let canonical: T; try { @@ -672,7 +715,11 @@ async function executeProviderWrite>( if (isAmbiguousFailure(trpcError, base.intent)) { return failProviderRowAmbiguous(base, row); } - await settleFailedProviderRow(base, row, outcomeCodeFromFailure(error, trpcError)); + await settleFailedProviderRow( + base, + row, + outcomeCodeFromFailure(error, trpcError), + ); throw trpcError; } // The write committed: settle completed at the committed-effect boundary. @@ -681,13 +728,21 @@ async function executeProviderWrite>( } /** 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; +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 }); + throw new TRPCError({ + code: "BAD_REQUEST", + message: PROVIDER_REPLAY_FAILED_MESSAGE, + }); } type ProviderLedgerMutationArgs = ProviderLedgerBase & { @@ -718,7 +773,7 @@ type ProviderLedgerMutationArgs = ProviderLedgerBase & { * fingerprint); a mismatch refuses the key reuse with no effect and no replay. */ async function runProviderLedgerMutation( - args: ProviderLedgerMutationArgs + args: ProviderLedgerMutationArgs, ): Promise> { const admission = await admitOperation(db, { userId: args.userId, @@ -726,27 +781,33 @@ async function runProviderLedgerMutation( intent: args.intent, operationKey: args.operationKey, resourceKey: args.resourceKey, - taxonomy: 'reconcile-first', + taxonomy: "reconcile-first", leaseSeconds: PROVIDER_LEDGER_LEASE_SECONDS, }); - if (admission.row.intent !== args.intent || admission.row.resource_key !== args.resourceKey) { + if ( + admission.row.intent !== args.intent || + admission.row.resource_key !== args.resourceKey + ) { throw new TRPCError({ - code: 'CONFLICT', + code: "CONFLICT", message: OPERATION_KEY_REUSE_MISMATCH_MESSAGE, }); } switch (admission.admission) { - case 'admitted': + case "admitted": return args.execute(admission.row); - case 'duplicate_settled': + 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': + 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); } } @@ -777,14 +838,21 @@ async function runProviderMutation>(args: { 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); + 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, + reconcile: args.reconcileAmbiguous + ? (row) => failProviderRowAmbiguous(base, row) + : execute, }); } @@ -807,23 +875,23 @@ async function reconcileMergeProviderRow>( /** 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' }; + | { 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' }; + 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' }; + ? { 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 @@ -831,29 +899,34 @@ async function reconcileMergeProviderRow>( } switch (state.kind) { - case 'merged': { + case "merged": { const canonical = { done: true, replayed: true }; - await settleCompletedProviderRow(base, row, canonical, 'confirmed_completed'); + await settleCompletedProviderRow( + base, + row, + canonical, + "confirmed_completed", + ); return { ...canonical, replayed: true } as unknown as ReplayedResult; } - case 'closed': - case 'stale_head': + case "closed": + case "stale_head": await settleFailedProviderRow( base, row, - state.kind === 'closed' ? 'already_closed' : 'head_moved', - 'confirmed_absent' + state.kind === "closed" ? "already_closed" : "head_moved", + "confirmed_absent", ); throw new TRPCError({ - code: 'CONFLICT', + code: "CONFLICT", message: - state.kind === 'stale_head' + 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': + case "lineage_intact": return executeProviderWrite(base, row, args.execute); - case 'unresolved': + case "unresolved": return failProviderRowAmbiguous(base, row); } } @@ -861,376 +934,465 @@ async function reconcileMergeProviderRow>( // ----- router ------------------------------------------------------------------ export const providerReviewRouter = createTRPCRouter({ - getPullRequest: baseProcedure.input(GetPullRequestInput).query(async ({ ctx, input }) => { - if (input.platform === 'gitlab') { - const owner = await gitlabOwner(ctx, input); + 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(() => - gitlabRead.getMergeRequest(owner, input.projectPath, input.mrIid, input.instanceHint) + bitbucketRead.getPullRequest( + owner, + input.workspace, + input.repoSlug, + input.prId, + ), ); - } - 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); + }), + + 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(() => - gitlabRead.listChecks(owner, input.projectPath, input.mrIid, input.instanceHint) + bitbucketRead.listChecks( + owner, + input.workspace, + input.repoSlug, + input.prId, + ), ); - } - 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); + }), + + 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(() => - gitlabRead.listChangedFiles( + bitbucketRead.listChangedFiles( owner, - input.projectPath, - input.mrIid, + input.workspace, + input.repoSlug, + input.prId, 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); + }), + + 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(() => - gitlabRead.getFileLines( + bitbucketRead.getFileLines( owner, - input.projectPath, + input.workspace, + input.repoSlug, 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); + }), + + 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(() => - gitlabRead.listDiscussions( + bitbucketRead.listDiscussions( owner, - input.projectPath, - input.mrIid, + input.workspace, + input.repoSlug, + input.prId, 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)); - }), + 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); + 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(() => - gitlabRead.getMergeState(owner, input.projectPath, input.mrIid, input.instanceHint) + bitbucketRead.getMergeRestrictions( + owner, + input.workspace, + input.repoSlug, + input.prId, + ), ); - } - const owner = await bitbucketOwner(ctx, input); - return providerCall(() => - bitbucketRead.getMergeRestrictions(owner, input.workspace, input.repoSlug, input.prId) - ); - }), - - /** Post a top-level comment. UGC-gated and ledgered like the GitHub path. */ - addComment: baseProcedure.input(AddCommentInput).mutation(async ({ ctx, input }) => { - await assertTermsAccepted(ctx.user.id); - if (input.platform === 'gitlab') { - const owner = await gitlabOwner(ctx, input); + }), + + /** 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: gitlabFingerprintInput(input, { body: input.body }), + intent: "create_review_comment", + fingerprintInput: bitbucketFingerprintInput(input, { + body: input.body, + ...anchorFields, + }), operationKey: input.operationKey, write: () => - gitlabAddComment({ + bitbucketAddComment({ owner, - projectPath: input.projectPath, - mrIid: input.mrIid, - instanceHint: input.instanceHint, + workspace: input.workspace, + repoSlug: input.repoSlug, + prId: input.prId, 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 }), - operationKey: input.operationKey, - write: () => - bitbucketAddComment({ - owner, - workspace: input.workspace, - repoSlug: input.repoSlug, - prId: input.prId, - body: input.body, - }), - 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); + 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: gitlabFingerprintInput(input, { - commentId: input.discussionId, + intent: "reply_comment", + fingerprintInput: bitbucketFingerprintInput(input, { + commentId: input.commentId, body: input.body, }), operationKey: input.operationKey, write: () => - gitlabReplyToComment({ + bitbucketReplyToComment({ owner, - projectPath: input.projectPath, - mrIid: input.mrIid, - instanceHint: input.instanceHint, - discussionId: input.discussionId, + workspace: input.workspace, + repoSlug: input.repoSlug, + prId: input.prId, + commentId: input.commentId, 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); + 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: gitlabFingerprintInput(input, { + intent: "submit_review", + fingerprintInput: bitbucketFingerprintInput(input, { event: input.event, body: input.body, + comments: input.comments, }), operationKey: input.operationKey, write: () => - gitlabSubmitReview({ + bitbucketSubmitReview({ owner, - projectPath: input.projectPath, - mrIid: input.mrIid, - instanceHint: input.instanceHint, + workspace: input.workspace, + repoSlug: input.repoSlug, + prId: input.prId, 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, - }), - operationKey: input.operationKey, - write: () => - bitbucketSubmitReview({ - owner, - workspace: input.workspace, - repoSlug: input.repoSlug, - prId: input.prId, - event: input.event, - body: input.body, - }), - reconcileAmbiguous: true, - }); - }), - - resolveThread: baseProcedure.input(ResolveThreadInput).mutation(async ({ ctx, input }) => { - if (input.platform === 'gitlab') { - const owner = await gitlabOwner(ctx, input); + }), + + 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: gitlabFingerprintInput(input, { threadId: input.discussionId }), + intent: "resolve_thread", + fingerprintInput: bitbucketFingerprintInput(input, { + threadId: input.threadId, + }), operationKey: input.operationKey, write: () => - gitlabResolveThread({ + bitbucketResolveThread({ owner, - projectPath: input.projectPath, - mrIid: input.mrIid, - instanceHint: input.instanceHint, - discussionId: input.discussionId, + workspace: input.workspace, + repoSlug: input.repoSlug, + prId: input.prId, + threadId: input.threadId, }), - // 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); + }), + + 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: gitlabFingerprintInput(input, { threadId: input.discussionId }), + intent: "unresolve_thread", + fingerprintInput: bitbucketFingerprintInput(input, { + threadId: input.threadId, + }), operationKey: input.operationKey, write: () => - gitlabUnresolveThread({ + bitbucketUnresolveThread({ owner, - projectPath: input.projectPath, - mrIid: input.mrIid, - instanceHint: input.instanceHint, - discussionId: input.discussionId, + workspace: input.workspace, + repoSlug: input.repoSlug, + prId: input.prId, + threadId: input.threadId, }), 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 @@ -1238,104 +1400,123 @@ export const providerReviewRouter = createTRPCRouter({ * 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, + 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, + }), + }); + } - if (input.platform === 'gitlab') { - const owner = await gitlabOwner(ctx, input); + const owner = await bitbucketOwner(ctx, input); const write = () => - gitlabMerge({ + bitbucketMerge({ owner, - projectPath: input.projectPath, - mrIid: input.mrIid, - instanceHint: input.instanceHint, + workspace: input.workspace, + repoSlug: input.repoSlug, + prId: input.prId, expectedHeadSha: mergeFields.expectedHeadSha, - squash: mergeFields.squash, - shouldRemoveSourceBranch: mergeFields.deleteBranch, - commitTitle: mergeFields.commitTitle, + closeSourceBranch: mergeFields.deleteBranch, commitMessage: mergeFields.commitMessage, }); if (input.operationKey === undefined) { return providerCall(write); } - const execute = (row: OperationLedgerRow) => executeProviderWrite(base, row, write); + const execute = (row: OperationLedgerRow) => + executeProviderWrite(base, row, write); return runProviderLedgerMutation({ ...base, operationKey: input.operationKey, - resourceKey: providerLedgerResourceKey('merge', ref, fingerprintInput), + resourceKey: providerLedgerResourceKey("merge", ref, fingerprintInput), execute, - reconcile: row => + 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. + // Owner-bound authoritative read — same identity as the write. readSummary: () => - gitlabRead.getMergeRequest(owner, input.projectPath, input.mrIid, input.instanceHint), + bitbucketRead.getPullRequest( + owner, + input.workspace, + input.repoSlug, + input.prId, + ), 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 @@ -1343,70 +1524,74 @@ export const providerReviewRouter = createTRPCRouter({ * 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, + 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, - }); - return { supported: true as const, reason: '', ...result }; - }, - reconcileAmbiguous: false, - }); - }), + }), + 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, + 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, - }); - return { supported: true as const, reason: '', ...result }; - }, - reconcileAmbiguous: false, - }); - }), + }), + 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/packages/app-shared/src/provider-review/contracts.ts b/packages/app-shared/src/provider-review/contracts.ts index c097641ffe..bad348a661 100644 --- a/packages/app-shared/src/provider-review/contracts.ts +++ b/packages/app-shared/src/provider-review/contracts.ts @@ -8,11 +8,11 @@ * difference never leaks past its mapper. */ -export type ProviderPrPlatform = 'github' | 'gitlab' | 'bitbucket'; +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'; + platform: "github"; owner: string; repo: string; number: number; @@ -25,7 +25,7 @@ export type GitHubPrRef = { * MUST never be used as an API base. */ export type GitLabMrRef = { - platform: 'gitlab'; + platform: "gitlab"; projectPath: string; mrIid: number; instanceHint?: string; @@ -33,7 +33,7 @@ export type GitLabMrRef = { /** A Bitbucket Cloud pull request: `workspace/repoSlug` plus the numeric `prId`. */ export type BitbucketPrRef = { - platform: 'bitbucket'; + platform: "bitbucket"; workspace: string; repoSlug: string; prId: number; @@ -57,17 +57,22 @@ export type ProviderPrRef = GitHubPrRef | GitLabMrRef | BitbucketPrRef; */ export function providerPrRefKey(ref: ProviderPrRef): string { switch (ref.platform) { - case 'github': - return JSON.stringify(['github', ref.owner, ref.repo, ref.number]); - case 'gitlab': + case "github": + return JSON.stringify(["github", ref.owner, ref.repo, ref.number]); + case "gitlab": return JSON.stringify([ - 'gitlab', + "gitlab", gitlabInstanceOrigin(ref.instanceHint), ref.projectPath, ref.mrIid, ]); - case 'bitbucket': - return JSON.stringify(['bitbucket', ref.workspace, ref.repoSlug, ref.prId]); + case "bitbucket": + return JSON.stringify([ + "bitbucket", + ref.workspace, + ref.repoSlug, + ref.prId, + ]); } } @@ -79,11 +84,11 @@ export function providerPrRefKey(ref: ProviderPrRef): string { * a hint can never collide with one pinned to the SaaS host. */ export function gitlabInstanceOrigin(instanceHint?: string): string { - if (!instanceHint) return ''; + 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] ?? ''; + return (rest.split("/")[0] ?? "").split("?")[0] ?? ""; } /** An author or reviewer identity. `login` is the provider username. */ @@ -93,10 +98,10 @@ export type ProviderPrAuthor = { }; /** The lifecycle state every provider maps onto. */ -export type ProviderPrState = 'open' | 'closed' | 'merged'; +export type ProviderPrState = "open" | "closed" | "merged"; /** Which side of a diff a comment or thread anchors to. */ -export type ProviderPrDiffSide = 'LEFT' | 'RIGHT'; +export type ProviderPrDiffSide = "LEFT" | "RIGHT"; /** * The diff position one inline review comment anchors to. `line` is the @@ -112,7 +117,9 @@ export type ProviderReviewInlineAnchor = { }; /** One inline comment inside a review submission batch. */ -export type ProviderReviewInlineComment = ProviderReviewInlineAnchor & { body: string }; +export type ProviderReviewInlineComment = ProviderReviewInlineAnchor & { + body: string; +}; /** * One PR/MR as the review screen renders it. Field shapes mirror what the @@ -215,13 +222,13 @@ export type ProviderPrChecksResult = { */ export type ProviderPrMergeBlockedReason = { code: - | 'conflicts' - | 'required_approvals' - | 'failing_pipeline' - | 'pending_pipeline' - | 'draft' - | 'permission' - | 'other'; + | "conflicts" + | "required_approvals" + | "failing_pipeline" + | "pending_pipeline" + | "draft" + | "permission" + | "other"; message: string; }; From 5efaf750cb4b578184e097e696fd13459202e5e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 7 Sep 2026 23:45:16 +0200 Subject: [PATCH 3/3] chore: anchored inline comments: mobile wiring through the provider seam (kwf bring-mobile-gitlab-and-bitb-3792/c3) --- .../merge/pr-merge-section-provider.test.tsx | 50 +- .../merge/pr-merge-section-provider.tsx | 17 +- .../pr-review/merge/pr-merge-sheet.test.tsx | 9 - ...-review-capability-banner.mounted.test.tsx | 38 +- .../pr-review/pr-review-capability-banner.tsx | 30 +- .../provider-review/bitbucket-write.test.ts | 794 +++++----- .../lib/provider-review/bitbucket-write.ts | 277 ++-- .../lib/provider-review/gitlab-write.test.ts | 651 ++++---- .../src/lib/provider-review/gitlab-write.ts | 247 ++-- .../routers/provider-review-router.test.ts | 776 +++++----- .../web/src/routers/provider-review-router.ts | 1304 ++++++++--------- .../src/provider-review/contracts.ts | 47 +- 12 files changed, 2010 insertions(+), 2230 deletions(-) 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 index 7e8ea65cf3..6ce4e0b65b 100644 --- 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 @@ -27,14 +27,6 @@ vi.mock('react-native', () => ({ ActivityIndicator: 'ActivityIndicator', })); -// The capability banner fades in as conditional content (AGENTS.md); the -// DOM-free renderer only needs the animated host as a string component. -vi.mock('react-native-reanimated', () => ({ - default: { View: 'Animated.View' }, - FadeIn: { duration: () => ({}) }, - FadeOut: { duration: () => ({}) }, -})); - vi.mock('@/components/ui/icons', () => ({ AlertTriangle: 'AlertTriangle', GitBranch: 'GitBranch', @@ -102,21 +94,6 @@ function findButton(renderer: TestRenderer.ReactTestRenderer, label: string) { return (button.props as { onPress?: () => void }).onPress; } -function findBanner(renderer: TestRenderer.ReactTestRenderer) { - return renderer.root.findAll( - node => - typeof node.type === 'function' && - (node.type as { name?: string }).name === 'PrReviewCapabilityBanner' - ); -} - -function textsOf(renderer: TestRenderer.ReactTestRenderer): string[] { - return renderer.root - .findAll(node => String(node.type) === 'Text') - .map(node => (node.props as { children?: unknown }).children) - .filter((child): child is string => typeof child === 'string'); -} - describe('PrMergeSectionProvider (s6)', () => { beforeEach(() => { routerPush.mockClear(); @@ -126,39 +103,24 @@ describe('PrMergeSectionProvider (s6)', () => { const renderer = await mount(GITLAB_REF, 'open'); expect(findButtons(renderer, 'Merge merge request')).toBe(1); expect(findButtons(renderer, 'Enable auto-merge')).toBe(1); - // Happy state: a supported capability renders no banner and no - // unavailable copy — the enable CTA is the affordance. - expect(findBanner(renderer)).toHaveLength(0); - expect(textsOf(renderer)).not.toContain('Auto-merge is not available'); renderer.unmount(); }); - it('offers merge with the localized capability banner on Bitbucket (auto-merge unsupported)', async () => { + 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); - // Non-retryable unhappy state: the banner explains, and carries no CTA. expect(findButtons(renderer, 'Enable auto-merge')).toBe(0); - const [banner] = findBanner(renderer); - // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- guard the one-banner invariant with a readable failure - if (!banner) { - throw new Error('the Bitbucket arm renders no capability banner'); - } + 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', }); - // The section hands catalog copy, not the shared English constant. - expect( - (banner.props as { title?: string; reason?: string }).title - ).toBe('Auto-merge is not available'); - expect( - (banner.props as { title?: string; reason?: string }).reason - ).toBe('Bitbucket Cloud does not expose auto-merge in its API'); - const texts = textsOf(renderer); - expect(texts).toContain('Auto-merge is not available'); - expect(texts).toContain('Bitbucket Cloud does not expose auto-merge in its API'); 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 index e9620825ac..cdd9093068 100644 --- 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 @@ -4,8 +4,8 @@ // 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 localized copy — never a dead button -// and never a silent absence. +// 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'; @@ -40,17 +40,6 @@ export function PrMergeSectionProvider({ prRef, state }: PrMergeSectionProviderP } const autoMerge = providerPrCapabilities(prRef.platform).autoMerge; - // The shared capability carries the provider's raw English reason. This - // section knows which capability it is (auto-merge) and which provider - // (Bitbucket), so it hands the banner catalog copy instead — apps/mobile - // ships translated copy, never a quoted constant. - const autoMergeUnavailableCopy = - prRef.platform === 'bitbucket' && !autoMerge.supported - ? { - title: t('prReview.capabilities.autoMergeUnavailable'), - reason: t('prReview.capabilities.autoMergeUnavailableReason'), - } - : undefined; const mergeLabel = t('prReview.merge.mergeTermTitle', { term: t(providerPrNounKey(prRef.platform)), }); @@ -86,7 +75,7 @@ export function PrMergeSectionProvider({ prRef, state }: PrMergeSectionProviderP {t('prReview.merge.enableAutoMerge')} ) : ( - + )} ); diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx index ef0ba7a06c..7af27b5987 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx @@ -100,15 +100,6 @@ vi.mock('react-native', () => ({ useWindowDimensions: () => ({ height: 800, width: 400 }), })); -// The capability banner fades in as conditional content (AGENTS.md); this -// suite builds element trees without mounting, so the animated host only -// needs to resolve in the node environment. -vi.mock('react-native-reanimated', () => ({ - default: { View: 'Animated.View' }, - FadeIn: { duration: () => ({}) }, - FadeOut: { duration: () => ({}) }, -})); - vi.mock('expo-haptics', () => ({ notificationAsync: vi.fn(), NotificationFeedbackType: { Success: 'Success' }, diff --git a/apps/mobile/src/components/pr-review/pr-review-capability-banner.mounted.test.tsx b/apps/mobile/src/components/pr-review/pr-review-capability-banner.mounted.test.tsx index 00b4019370..456b4794d1 100644 --- a/apps/mobile/src/components/pr-review/pr-review-capability-banner.mounted.test.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-capability-banner.mounted.test.tsx @@ -20,11 +20,6 @@ vi.mock('react-i18next', async importOriginal => { }); vi.mock('react-native', () => ({ View: 'View' })); -vi.mock('react-native-reanimated', () => ({ - default: { View: 'Animated.View' }, - FadeIn: { duration: () => ({}) }, - FadeOut: { duration: () => ({}) }, -})); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); const UNSUPPORTED: ProviderReviewCapability = { @@ -34,14 +29,11 @@ const UNSUPPORTED: ProviderReviewCapability = { const SUPPORTED: ProviderReviewCapability = { supported: true, reason: '' }; function renderBanner( - capability: ProviderReviewCapability | undefined, - overrides?: { title?: string; reason?: string } + capability: ProviderReviewCapability | undefined ): TestRenderer.ReactTestRenderer { let renderer: TestRenderer.ReactTestRenderer | null = null; act(() => { - renderer = TestRenderer.create( - createElement(PrReviewCapabilityBanner, { capability, ...overrides }) - ); + 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) { @@ -69,37 +61,13 @@ describe('PrReviewCapabilityBanner', () => { it('announces title and reason together for accessibility', () => { const renderer = renderBanner(UNSUPPORTED); - const view = renderer.root.find(node => (node.type as string) === 'Animated.View'); + 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 a localized title/reason override instead of the generic banner copy', () => { - const renderer = renderBanner(UNSUPPORTED, { - title: 'Auto-merge is not available', - reason: 'Bitbucket Cloud does not expose auto-merge in its API', - }); - const texts = textsOf(renderer); - expect(texts).toContain('Auto-merge is not available'); - expect(texts).toContain('Bitbucket Cloud does not expose auto-merge in its API'); - expect(texts).not.toContain('Not available on this provider'); - renderer.unmount(); - }); - - it('keeps capability.reason as the fallback when no override is passed', () => { - const unknownCapability: ProviderReviewCapability = { - supported: false, - reason: 'Some provider answers a reason the catalog does not name', - }; - const renderer = renderBanner(unknownCapability); - const texts = textsOf(renderer); - expect(texts).toContain('Not available on this provider'); - expect(texts).toContain(unknownCapability.reason); - renderer.unmount(); - }); - it('renders nothing for a supported capability — the affordance itself shows', () => { const renderer = renderBanner(SUPPORTED); expect(renderer.toJSON()).toBeNull(); 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 index a003b0dde6..1dcda5690c 100644 --- a/apps/mobile/src/components/pr-review/pr-review-capability-banner.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-capability-banner.tsx @@ -4,14 +4,9 @@ // 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. -// The provider's raw reason string is only the fallback: a surface that -// recognizes the capability (auto-merge on Bitbucket) passes catalog copy -// through `title`/`reason`, so the explanation is translated, not quoted. -// The banner is conditional content below a section, so it fades in/out on -// mount transitions instead of jumping the layout (AGENTS.md). import { useTranslation } from 'react-i18next'; -import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { View } from 'react-native'; import { type ProviderReviewCapability } from '@kilocode/app-shared/provider-review'; @@ -19,35 +14,24 @@ import { Text } from '@/components/ui/text'; export function PrReviewCapabilityBanner({ capability, - title, - reason, -}: Readonly<{ - capability: ProviderReviewCapability | undefined; - /** Localized title override for a capability the catalog names. */ - title?: string; - /** Localized reason override; `capability.reason` stays the fallback. */ - reason?: string; -}>) { +}: 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; } - const bannerReason = reason ?? capability.reason; return ( - - {title ?? t('prReview.capabilities.banner.title')} + {t('prReview.capabilities.banner.title')} - {bannerReason} - + {capability.reason} + ); } diff --git a/apps/web/src/lib/provider-review/bitbucket-write.test.ts b/apps/web/src/lib/provider-review/bitbucket-write.test.ts index 6d8c6ad0d5..e83eb45208 100644 --- a/apps/web/src/lib/provider-review/bitbucket-write.test.ts +++ b/apps/web/src/lib/provider-review/bitbucket-write.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach, afterEach } from "@jest/globals"; +import { describe, expect, it, beforeEach, afterEach } from '@jest/globals'; import { addComment, BITBUCKET_AUTO_MERGE_UNSUPPORTED_REASON, @@ -10,52 +10,49 @@ import { replyToComment, resolveThread, submitReview, -} from "./bitbucket-write"; -import { BitbucketReviewError } from "./bitbucket-authorization"; +} 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/integrations/platforms/bitbucket/workspace-access-token-repository-cache', () => ({ + getBitbucketWorkspaceAccessTokenStatus: (...args: unknown[]) => + mockGetBitbucketWorkspaceAccessTokenStatus(...args), + readCachedBitbucketWorkspaceAccessTokenRepositories: (input: unknown) => + mockReadCachedRepositories(input), })); -jest.mock("@/lib/tokens", () => ({ - generateInternalServiceToken: jest.fn(() => "svc-mock-token"), +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", () => ({ +jest.mock('@/lib/utils.server', () => ({ logExceptInTest: () => {}, warnExceptInTest: () => {}, })); const ORG_OWNER = { - type: "organization" as const, - organizationId: "org_1", - userId: "user_1", + type: 'organization' as const, + organizationId: 'org_1', + userId: 'user_1', }; const WORKSPACE = { - uuid: "12345678-1234-1234-1234-123456789012", - slug: "acme", + uuid: '12345678-1234-1234-1234-123456789012', + slug: 'acme', }; -const HEAD_SHA = "abc123def4567890"; +const HEAD_SHA = 'abc123def4567890'; const openPr = { id: 12, - state: "OPEN", + state: 'OPEN', source: { commit: { hash: HEAD_SHA } }, }; @@ -64,66 +61,63 @@ let fetchMock: jest.Mock; function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, - headers: { "content-type": "application/json" }, + headers: { 'content-type': 'application/json' }, }); } /** Await a rejection and return it typed, without a success-branch union. */ -async function captureRejection( - promise: Promise, -): Promise { +async function captureRejection(promise: Promise): Promise { try { await promise; } catch (reason) { return reason as BitbucketReviewError; } - throw new Error("Expected the call to reject."); + throw new Error('Expected the call to reject.'); } beforeEach(() => { jest.clearAllMocks(); mockGetBitbucketWorkspaceAccessTokenStatus.mockResolvedValue({ - status: "connected", - integrationId: "intg_1", - workspace: { ...WORKSPACE, displayName: "Acme" }, + status: 'connected', + integrationId: 'intg_1', + workspace: { ...WORKSPACE, displayName: 'Acme' }, }); mockReadCachedRepositories.mockResolvedValue({ - status: "available", + status: 'available', repositories: [ { - id: "87654321-4321-4321-4321-210987654321", + id: '87654321-4321-4321-4321-210987654321', workspaceUuid: WORKSPACE.uuid, - name: "repo", - fullName: "acme/repo", + name: 'repo', + fullName: 'acme/repo', private: true, - defaultBranch: "main", + defaultBranch: 'main', }, ], - syncedAt: "2026-09-06T00:00:00.000Z", + 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")) { + if (url.toString().includes('token-service.example.com')) { return jsonResponse({ - status: "available", - token: "at-mock-token", + status: 'available', + token: 'at-mock-token', workspace: WORKSPACE, }); } - if (parsed.pathname === "/2.0/user") { - return jsonResponse({ uuid: "{current-user-uuid}" }); + if (parsed.pathname === '/2.0/user') { + return jsonResponse({ uuid: '{current-user-uuid}' }); } - if (parsed.pathname.endsWith("/pullrequests/12")) - return jsonResponse(openPr); - if (parsed.pathname.endsWith("/tasks")) { + 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")) { + 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 }); @@ -139,92 +133,110 @@ afterEach(() => { function bitbucketCalls(): Array<{ url: URL; init: Record }> { return fetchMock.mock.calls - .map((call) => ({ + .map(call => ({ url: new URL(String(call[0])), init: (call[1] ?? {}) as Record, })) - .filter((call) => call.url.hostname === "api.bitbucket.org"); + .filter(call => call.url.hostname === 'api.bitbucket.org'); } -describe("addComment", () => { - it("posts the raw content to the pull request comments collection", async () => { +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", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - body: "A review comment", + 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", - ); + 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: "A review comment" }, + content: { raw: 'Inline on the new side' }, + inline: { path: 'src/deploy.ts', to: 42 }, }); }); - it("a RIGHT anchor posts an inline comment anchored on the destination line", async () => { + it('a LEFT anchor posts an inline comment anchored on the source line', async () => { await addComment({ owner: ORG_OWNER, - workspace: "acme", - repoSlug: "repo", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - body: "Inline on the new side", - anchor: { path: "src/deploy.ts", side: "RIGHT", line: 42 }, + body: 'Inline on the old side', + anchor: { path: 'src/deploy.ts', side: 'LEFT', line: 7 }, }); - const post = bitbucketCalls().find((call) => call.init.method === "POST"); + 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 }, + content: { raw: 'Inline on the old side' }, + inline: { path: 'src/deploy.ts', from: 7 }, }); }); - it("a LEFT anchor posts an inline comment anchored on the source line", async () => { + it('a RIGHT startLine range anchors the destination line, never an unrelated source line', async () => { await addComment({ owner: ORG_OWNER, - workspace: "acme", - repoSlug: "repo", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - body: "Inline on the old side", - anchor: { path: "src/deploy.ts", side: "LEFT", line: 7 }, + body: 'This block', + anchor: { path: 'src/deploy.ts', side: 'RIGHT', line: 20, startLine: 10 }, }); - const post = bitbucketCalls().find((call) => call.init.method === "POST"); + 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: "Inline on the old side" }, - inline: { path: "src/deploy.ts", from: 7 }, + content: { raw: 'This block' }, + inline: { path: 'src/deploy.ts', to: 20 }, }); }); - it("a startLine range spans from the first line to the anchor line", async () => { + 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", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - body: "This block", - anchor: { path: "src/deploy.ts", side: "RIGHT", line: 20, startLine: 10 }, + body: 'This block', + anchor: { path: 'src/deploy.ts', side: 'LEFT', line: 20, startLine: 10 }, }); - const post = bitbucketCalls().find((call) => call.init.method === "POST"); + 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: 10, to: 20 }, + content: { raw: 'This block' }, + inline: { path: 'src/deploy.ts', from: 20 }, }); }); - it("classifies a provider 400 on an anchored comment as bad_request", async () => { + 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")) { + if (full.includes('token-service.example.com')) { return jsonResponse({ - status: "available", - token: "at-mock-token", + status: 'available', + token: 'at-mock-token', workspace: WORKSPACE, }); } @@ -234,259 +246,244 @@ describe("addComment", () => { const error = await captureRejection( addComment({ owner: ORG_OWNER, - workspace: "acme", - repoSlug: "repo", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - body: "x", - anchor: { path: "src/deploy.ts", side: "RIGHT", line: 999_999 }, - }), + body: 'x', + anchor: { path: 'src/deploy.ts', side: 'RIGHT', line: 999_999 }, + }) ); - expect(error.kind).toBe("bad_request"); + expect(error.kind).toBe('bad_request'); expect(error.retryable).toBe(false); }); - it("maps a provider 403 to non-retryable forbidden", async () => { + 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")) { + if (full.includes('token-service.example.com')) { return jsonResponse({ - status: "available", - token: "at-mock-token", + status: 'available', + token: 'at-mock-token', workspace: WORKSPACE, }); } - if (new URL(full).pathname === "/2.0/user") - return jsonResponse({ uuid: "{u}" }); + if (new URL(full).pathname === '/2.0/user') return jsonResponse({ uuid: '{u}' }); return new Response(null, { status: 403 }); }); const error = await captureRejection( addComment({ owner: ORG_OWNER, - workspace: "acme", - repoSlug: "repo", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - body: "Nope", - }), + body: 'Nope', + }) ); expect(error).toBeInstanceOf(BitbucketReviewError); - expect(error.kind).toBe("forbidden"); + expect(error.kind).toBe('forbidden'); expect(error.retryable).toBe(false); }); }); -describe("replyToComment", () => { - it("posts a reply carrying the parent comment id", async () => { +describe('replyToComment', () => { + it('posts a reply carrying the parent comment id', async () => { const result = await replyToComment({ owner: ORG_OWNER, - workspace: "acme", - repoSlug: "repo", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - commentId: "101", - body: "A reply", + 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", - ); + 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" }, + content: { raw: 'A reply' }, parent: { id: 101 }, }); }); - it("refuses a non-numeric comment id as bad_request without any provider call", async () => { + 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", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - commentId: "not-a-number", - body: "A reply", - }), + commentId: 'not-a-number', + body: 'A reply', + }) ); - expect(error.kind).toBe("bad_request"); + expect(error.kind).toBe('bad_request'); expect(bitbucketCalls()).toEqual([]); }); }); -describe("submitReview", () => { - it("maps approve to the participants state approved for the connected identity", async () => { +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", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - event: "approve", + event: 'approve', }); expect(result).toEqual({ done: true, replayed: false }); const put = bitbucketCalls().find( - (call) => - call.init.method === "PUT" && - call.url.pathname.includes("/participants/"), + call => call.init.method === 'PUT' && call.url.pathname.includes('/participants/') ); expect(put?.url.pathname).toBe( - "/2.0/repositories/acme/repo/pullrequests/12/participants/%7Bcurrent-user-uuid%7D", + '/2.0/repositories/acme/repo/pullrequests/12/participants/%7Bcurrent-user-uuid%7D' ); - expect(JSON.parse(String(put?.init.body))).toEqual({ state: "approved" }); + expect(JSON.parse(String(put?.init.body))).toEqual({ state: 'approved' }); }); - it("maps request_changes to participants state changes_requested", async () => { + it('maps request_changes to participants state changes_requested', async () => { const result = await submitReview({ owner: ORG_OWNER, - workspace: "acme", - repoSlug: "repo", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - event: "request_changes", + event: 'request_changes', }); expect(result).toEqual({ done: true, replayed: false }); const put = bitbucketCalls().find( - (call) => - call.init.method === "PUT" && - call.url.pathname.includes("/participants/"), + call => call.init.method === 'PUT' && call.url.pathname.includes('/participants/') ); expect(JSON.parse(String(put?.init.body))).toEqual({ - state: "changes_requested", + state: 'changes_requested', }); }); - it("maps comment to clearing the own approval state and posts the body", async () => { + 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", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - event: "comment", - body: "Read this first", + 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/"), + 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"); + const post = bitbucketCalls().find(call => call.init.method === 'POST'); expect(JSON.parse(String(post?.init.body))).toEqual({ - content: { raw: "Read this first" }, + content: { raw: 'Read this first' }, }); }); - it("refuses a comment review without a body before any provider call", async () => { + 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", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - event: "comment", - }), + event: 'comment', + }) ); - expect(error.kind).toBe("bad_request"); + expect(error.kind).toBe('bad_request'); expect(bitbucketCalls()).toEqual([]); }); - it("posts every inline comment before the review state and the summary comment", async () => { + 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", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - event: "approve", - body: "LGTM", + event: 'approve', + body: 'LGTM', comments: [ - { path: "a.ts", side: "RIGHT", line: 3, body: "first inline" }, + { path: 'a.ts', side: 'RIGHT', line: 3, body: 'first inline' }, { - path: "b.ts", - side: "LEFT", + path: 'b.ts', + side: 'LEFT', line: 9, startLine: 4, - body: "second inline", + body: 'second inline', }, ], }); expect(result).toEqual({ done: true, replayed: false }); const effects = bitbucketCalls().filter( - (call) => call.init.method === "POST" || call.init.method === "PUT", + 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/%7Bcurrent-user-uuid%7D", - "POST /2.0/repositories/acme/repo/pullrequests/12/comments", + 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/%7Bcurrent-user-uuid%7D', + '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 }, + content: { raw: 'first inline' }, + inline: { path: 'a.ts', to: 3 }, }); expect(JSON.parse(String(effects[1].init.body))).toEqual({ - content: { raw: "second inline" }, - // A startLine range spans from the first line to the anchor line. - inline: { path: "b.ts", from: 4, to: 9 }, + 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" }, + content: { raw: 'LGTM' }, }); }); - it("a comment event with a batch and no body still posts the inline comments and clears approval", async () => { + 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", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - event: "comment", - comments: [{ path: "a.ts", side: "RIGHT", line: 3, body: "inline only" }], + 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", + 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 }, + 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 provider rejection surfaces the classified error and stops the batch", async () => { + 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")) { + if (full.includes('token-service.example.com')) { return jsonResponse({ - status: "available", - token: "at-mock-token", + status: 'available', + token: 'at-mock-token', workspace: WORKSPACE, }); } const parsed = new URL(full); - if (parsed.pathname === "/2.0/user") - return jsonResponse({ uuid: "{current-user-uuid}" }); - if (parsed.pathname.endsWith("/comments")) { + if (parsed.pathname === '/2.0/user') return jsonResponse({ uuid: '{current-user-uuid}' }); + if (parsed.pathname.endsWith('/comments')) { posts += 1; return posts === 1 ? jsonResponse({ id: 101 }) - : jsonResponse( - { error: { message: "inline position invalid" } }, - 400, - ); + : jsonResponse({ error: { message: 'inline position invalid' } }, 400); } return jsonResponse({}); }); @@ -494,91 +491,170 @@ describe("submitReview", () => { const error = await captureRejection( submitReview({ owner: ORG_OWNER, - workspace: "acme", - repoSlug: "repo", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - event: "approve", - body: "LGTM", + event: 'approve', + body: 'LGTM', comments: [ - { path: "a.ts", side: "RIGHT", line: 3, body: "first" }, - { path: "b.ts", side: "RIGHT", line: 4, body: "outside" }, + { path: 'a.ts', side: 'RIGHT', line: 3, body: 'first' }, + { path: 'b.ts', side: 'RIGHT', line: 4, body: 'outside' }, ], - }), + }) ); - expect(error.kind).toBe("bad_request"); - expect(error.retryable).toBe(false); + // 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(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 === '/2.0/user') return jsonResponse({ uuid: '{current-user-uuid}' }); + 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 === '/2.0/user') return jsonResponse({ uuid: '{current-user-uuid}' }); + 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 () => { +describe('resolveThread', () => { + it('resolves the comment task when one exists', async () => { const result = await resolveThread({ owner: ORG_OWNER, - workspace: "acme", - repoSlug: "repo", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - threadId: "101", + 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", - ); + 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 () => { + 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")) { + if (full.includes('token-service.example.com')) { return jsonResponse({ - status: "available", - token: "at-mock-token", + 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('/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", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - threadId: "101", - }), + threadId: '101', + }) ); - expect(error.kind).toBe("bad_request"); + 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 () => { + 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")) { + if (full.includes('token-service.example.com')) { return jsonResponse({ - status: "available", - token: "at-mock-token", + status: 'available', + token: 'at-mock-token', workspace: WORKSPACE, }); } const parsed = new URL(full); - if (parsed.pathname === "/2.0/user") return jsonResponse({ uuid: "{u}" }); - if (parsed.pathname.endsWith("/comments/101")) - return jsonResponse({ id: 101 }); - if (parsed.pathname.endsWith("/tasks")) { + if (parsed.pathname === '/2.0/user') return jsonResponse({ uuid: '{u}' }); + 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 } }], @@ -591,74 +667,69 @@ describe("resolveThread", () => { const error = await captureRejection( resolveThread({ owner: ORG_OWNER, - workspace: "acme", - repoSlug: "repo", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - threadId: "101", - }), + threadId: '101', + }) ); - expect(error.kind).toBe("bad_request"); + 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, - ); + expect(bitbucketCalls().some(call => call.init.method === 'PUT')).toBe(false); }); - it("refuses with the capability reason when the task collection is not exposed", async () => { + 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")) { + if (full.includes('token-service.example.com')) { return jsonResponse({ - status: "available", - token: "at-mock-token", + status: 'available', + token: 'at-mock-token', workspace: WORKSPACE, }); } const parsed = new URL(full); - if (parsed.pathname === "/2.0/user") return jsonResponse({ uuid: "{u}" }); - if (parsed.pathname.endsWith("/comments/101")) - return jsonResponse({ id: 101 }); - if (parsed.pathname.endsWith("/tasks")) - return new Response(null, { status: 404 }); + if (parsed.pathname === '/2.0/user') return jsonResponse({ uuid: '{u}' }); + 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", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - threadId: "101", - }), + threadId: '101', + }) ); - expect(error.kind).toBe("bad_request"); + 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 () => { + 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")) { + if (full.includes('token-service.example.com')) { return jsonResponse({ - status: "available", - token: "at-mock-token", + status: 'available', + token: 'at-mock-token', workspace: WORKSPACE, }); } const parsed = new URL(full); - if (parsed.pathname === "/2.0/user") return jsonResponse({ uuid: "{u}" }); - if (parsed.pathname.endsWith("/comments/101")) - return jsonResponse({ id: 101 }); - if (parsed.pathname.endsWith("/tasks")) { + if (parsed.pathname === '/2.0/user') return jsonResponse({ uuid: '{u}' }); + 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", + resolved_on: '2026-09-06T00:00:00.000Z', comment: { id: 101 }, }, ], @@ -670,33 +741,30 @@ describe("resolveThread", () => { const result = await resolveThread({ owner: ORG_OWNER, - workspace: "acme", - repoSlug: "repo", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - threadId: "101", + threadId: '101', }); expect(result).toEqual({ done: true, replayed: true }); - expect(bitbucketCalls().some((call) => call.init.method === "PUT")).toBe( - false, - ); + 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 () => { + 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")) { + if (full.includes('token-service.example.com')) { return jsonResponse({ - status: "available", - token: "at-mock-token", + 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" + 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 } }], @@ -705,7 +773,7 @@ describe("resolveThread", () => { : 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", + next: 'https://api.bitbucket.org/2.0/repositories/acme/repo/pullrequests/12/tasks?pagelen=100&page=2', }); } return jsonResponse({ pagelen: 50, values: [], next: null }); @@ -713,45 +781,40 @@ describe("resolveThread", () => { const result = await resolveThread({ owner: ORG_OWNER, - workspace: "acme", - repoSlug: "repo", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - threadId: "101", + 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", - ); + 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); + expect(bitbucketCalls().filter(call => call.url.pathname.endsWith('/tasks'))).toHaveLength(2); }); - it("concludes replayed only after the whole task collection is exhausted", async () => { + 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")) { + if (full.includes('token-service.example.com')) { return jsonResponse({ - status: "available", - token: "at-mock-token", + 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" + 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", + resolved_on: '2026-09-06T00:00:00.000Z', comment: { id: 101 }, }, ], @@ -760,7 +823,7 @@ describe("resolveThread", () => { : 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", + next: 'https://api.bitbucket.org/2.0/repositories/acme/repo/pullrequests/12/tasks?pagelen=100&page=2', }); } return jsonResponse({ pagelen: 50, values: [], next: null }); @@ -768,90 +831,84 @@ describe("resolveThread", () => { const result = await resolveThread({ owner: ORG_OWNER, - workspace: "acme", - repoSlug: "repo", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - threadId: "101", + threadId: '101', }); expect(result).toEqual({ done: true, replayed: true }); - expect(bitbucketCalls().some((call) => call.init.method === "PUT")).toBe( - false, - ); + 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 () => { + 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", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - threadId: "not-a-number", - }), + threadId: 'not-a-number', + }) ); - expect(error.kind).toBe("not_found"); + 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 () => { +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", + workspace: 'acme', + repoSlug: 'repo', prId: 12, expectedHeadSha: HEAD_SHA, closeSourceBranch: true, - commitMessage: "Merged in feature/retry", + 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", - ); + const post = bitbucketCalls().find(call => call.init.method === 'POST'); + expect(post?.url.pathname).toBe('/2.0/repositories/acme/repo/pullrequests/12/merge'); expect(JSON.parse(String(post?.init.body))).toEqual({ close_source_branch: true, - commit_message: "Merged in feature/retry", + commit_message: 'Merged in feature/retry', }); }); - it("refuses a stale revision with the exact reason and never merges", async () => { + 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", + workspace: 'acme', + repoSlug: 'repo', prId: 12, - expectedHeadSha: "stale-sha", - }), + expectedHeadSha: 'stale-sha', + }) ); - expect(error.kind).toBe("stale_head"); + 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); + expect(bitbucketCalls().some(call => call.url.pathname.endsWith('/merge'))).toBe(false); }); - it("reports replayed when the PR is already merged", async () => { + 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")) { + if (full.includes('token-service.example.com')) { return jsonResponse({ - status: "available", - token: "at-mock-token", + status: 'available', + token: 'at-mock-token', workspace: WORKSPACE, }); } const parsed = new URL(full); - if (parsed.pathname.endsWith("/pullrequests/12")) { + if (parsed.pathname.endsWith('/pullrequests/12')) { return jsonResponse({ id: 12, - state: "MERGED", + state: 'MERGED', source: { commit: { hash: HEAD_SHA } }, }); } @@ -860,33 +917,31 @@ describe("mergePullRequest", () => { const result = await mergePullRequest({ owner: ORG_OWNER, - workspace: "acme", - repoSlug: "repo", + 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, - ); + expect(bitbucketCalls().some(call => call.init.method === 'POST')).toBe(false); }); - it("refuses a closed PR with a non-retryable bad_request", async () => { + 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")) { + if (full.includes('token-service.example.com')) { return jsonResponse({ - status: "available", - token: "at-mock-token", + status: 'available', + token: 'at-mock-token', workspace: WORKSPACE, }); } const parsed = new URL(full); - if (parsed.pathname.endsWith("/pullrequests/12")) { + if (parsed.pathname.endsWith('/pullrequests/12')) { return jsonResponse({ id: 12, - state: "DECLINED", + state: 'DECLINED', source: { commit: { hash: HEAD_SHA } }, }); } @@ -896,48 +951,47 @@ describe("mergePullRequest", () => { const error = await captureRejection( mergePullRequest({ owner: ORG_OWNER, - workspace: "acme", - repoSlug: "repo", + workspace: 'acme', + repoSlug: 'repo', prId: 12, expectedHeadSha: HEAD_SHA, - }), + }) ); - expect(error.kind).toBe("bad_request"); + 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", () => { +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", + reason: 'Bitbucket Cloud does not expose auto-merge in its API', }); }); - it("reactions are unsupported with the provider reason", () => { + 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", + reason: 'Bitbucket Cloud does not expose reactions on pull request comments', }); }); - it("review events include request_changes", () => { + it('review events include request_changes', () => { expect(BITBUCKET_PR_REVIEW_CAPABILITIES.reviewEvents).toEqual([ - "approve", - "request_changes", - "comment", + 'approve', + 'request_changes', + 'comment', ]); }); - it("the exported reasons match the shared capability copy", () => { + it('the exported reasons match the shared capability copy', () => { expect(BITBUCKET_AUTO_MERGE_UNSUPPORTED_REASON).toBe( - BITBUCKET_PR_REVIEW_CAPABILITIES.autoMerge.reason, + BITBUCKET_PR_REVIEW_CAPABILITIES.autoMerge.reason ); expect(BITBUCKET_REACTIONS_UNSUPPORTED_REASON).toBe( - BITBUCKET_PR_REVIEW_CAPABILITIES.reactions.reason, + 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 index 33387d3c90..9a49d45b02 100644 --- a/apps/web/src/lib/provider-review/bitbucket-write.ts +++ b/apps/web/src/lib/provider-review/bitbucket-write.ts @@ -9,27 +9,23 @@ * 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 'server-only'; -import { z } from "zod"; +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"; +} 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"; +} from './bitbucket-authorization'; +import { fetchPage, requestBitbucketJson, repositoryPathGuard } from './bitbucket-read'; /** * The Bitbucket capability list for review surfaces. It reuses the shared @@ -55,14 +51,14 @@ export const BITBUCKET_REACTIONS_UNSUPPORTED_REASON = * 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"; + '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 pull request changed since it was loaded. Reload the pull request and try again.'; /** The PR a write acts on. */ export type BitbucketPrTarget = { @@ -85,7 +81,7 @@ const BitbucketCurrentUserSchema = z.object({ uuid: z.string().min(1) }); const BitbucketPullRequestWriteSchema = z.object({ id: z.number(), - state: z.enum(["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]), + state: z.enum(['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED']), source: z .object({ commit: z @@ -118,9 +114,7 @@ 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 { +async function targetAccess(target: BitbucketPrTarget): Promise { return authorizeRepository(target.owner, target.workspace, target.repoSlug); } @@ -129,27 +123,24 @@ async function targetAccess( * review states against. Resolved from the workspace access token itself, so * it is always the identity the provider will act as. */ -async function ownAccountId( - access: BitbucketRepositoryAccess, -): Promise { +async function ownAccountId(access: BitbucketRepositoryAccess): Promise { const user = BitbucketCurrentUserSchema.parse( - await requestBitbucketJson(access, "/2.0/user"), + await requestBitbucketJson(access, '/2.0/user') ); return user.uuid; } /** * The Bitbucket `inline` block for one anchor: RIGHT anchors the destination - * line (`to`), LEFT the source line (`from`); a `startLine` range spans - * `from: startLine` to `to: line`. + * 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 { - if (anchor.startLine !== undefined) { - return { path: anchor.path, from: anchor.startLine, to: anchor.line }; - } - return anchor.side === "RIGHT" +function buildInlinePosition(anchor: ProviderReviewInlineAnchor): Record { + return anchor.side === 'RIGHT' ? { path: anchor.path, to: anchor.line } : { path: anchor.path, from: anchor.line }; } @@ -163,23 +154,17 @@ export async function addComment( target: BitbucketPrTarget & { body: string; anchor?: ProviderReviewInlineAnchor; - } & BitbucketMutationInput, + } & 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) } - : {}), - }, + 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); @@ -191,94 +176,104 @@ export async function replyToComment( target: BitbucketPrTarget & { commentId: string; body: string; - } & BitbucketMutationInput, + } & 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.", - ); + 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 } }, - }, - ); + 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; a - * mid-batch failure throws the classified error and the router's - * reconcile-ambiguous handling keeps the ledger from replaying a duplicate. + * 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"; + event: 'approve' | 'request_changes' | 'comment'; body?: string; comments?: ProviderReviewInlineComment[]; - } & BitbucketMutationInput, + } & BitbucketMutationInput ): Promise { - if (target.event === "comment" && !target.body && !target.comments?.length) { - throw new BitbucketReviewError( - "bad_request", - "A comment review needs a body.", - ); + 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 ?? []) { - await requestBitbucketJson( - access, - `${prPath(access, target.prId)}/comments`, - { - method: "POST", + 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" + 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 } }, + { method: 'PUT', body: { state } } ); if (target.body) { - await requestBitbucketJson( - access, - `${prPath(access, target.prId)}/comments`, - { - method: "POST", - body: { content: { raw: target.body } }, - }, - ); + await requestBitbucketJson(access, `${prPath(access, target.prId)}/comments`, { + method: 'POST', + body: { content: { raw: target.body } }, + }); } return { done: true, replayed: false }; } catch (error) { - throw classifyBitbucketError(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; } } @@ -295,7 +290,7 @@ async function findCommentTask( access: BitbucketRepositoryAccess, prId: number, commentId: number, - predicate: (task: z.infer) => boolean, + predicate: (task: z.infer) => boolean ): Promise<{ task: z.infer | null; sawTaskForComment: boolean; @@ -306,18 +301,14 @@ async function findCommentTask( let cursor: string | undefined = undefined; let exhausted = true; try { - for ( - let pageIndex = 0; - pageIndex < MAX_TASK_COLLECTION_PAGES; - pageIndex++ - ) { + 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 }, + { pagelen: 100 } ); for (const value of page.values) { const parsed = BitbucketTaskWriteSchema.safeParse(value); @@ -338,11 +329,8 @@ async function findCommentTask( cursor = page.nextCursor; } } catch (error) { - if (error instanceof BitbucketReviewError && error.kind === "not_found") { - throw new BitbucketReviewError( - "bad_request", - BITBUCKET_THREAD_RESOLUTION_UNSUPPORTED_REASON, - ); + if (error instanceof BitbucketReviewError && error.kind === 'not_found') { + throw new BitbucketReviewError('bad_request', BITBUCKET_THREAD_RESOLUTION_UNSUPPORTED_REASON); } throw error; } @@ -357,14 +345,11 @@ async function findCommentTask( * the explicit capability reason — never a silent fallback. */ export async function resolveThread( - target: BitbucketPrTarget & { threadId: string } & BitbucketMutationInput, + 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.", - ); + throw new BitbucketReviewError('not_found', 'This discussion thread could not be found.'); } const access = await targetAccess(target); try { @@ -373,15 +358,15 @@ export async function resolveThread( BitbucketCommentWriteSchema.parse( await requestBitbucketJson( access, - `${prPath(access, target.prId)}/comments/${commentId}`, - ), + `${prPath(access, target.prId)}/comments/${commentId}` + ) ); const { task, sawTaskForComment, exhausted } = await findCommentTask( access, target.prId, commentId, - (candidate) => candidate.resolved_on == null, + candidate => candidate.resolved_on == null ); if (!task) { if (!exhausted) { @@ -389,8 +374,8 @@ export async function resolveThread( // 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.", + 'retryable', + 'The Bitbucket task list is too large to resolve this thread. Try again.' ); } if (sawTaskForComment) { @@ -400,19 +385,12 @@ export async function resolveThread( } // 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, - ); + 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 }, - }, - ); + 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); @@ -426,54 +404,44 @@ export async function resolveThread( * refused with the explicit capability reason. */ export async function unresolveThread( - target: BitbucketPrTarget & { threadId: string } & BitbucketMutationInput, + 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.", - ); + 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}`, - ), + `${prPath(access, target.prId)}/comments/${commentId}` + ) ); const { task, sawTaskForComment, exhausted } = await findCommentTask( access, target.prId, commentId, - (candidate) => candidate.resolved_on != null, + 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.", + '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, - ); + 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 }, - }, - ); + 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); @@ -487,11 +455,11 @@ export async function unresolveThread( */ function requireHeadShaFence( pr: z.infer, - expectedHeadSha: string, + expectedHeadSha: string ): void { - const currentHead = pr.source?.commit?.hash ?? ""; + const currentHead = pr.source?.commit?.hash ?? ''; if (currentHead !== expectedHeadSha) { - throw new BitbucketReviewError("stale_head", BITBUCKET_STALE_HEAD_REASON); + throw new BitbucketReviewError('stale_head', BITBUCKET_STALE_HEAD_REASON); } } @@ -505,31 +473,26 @@ export async function mergePullRequest( expectedHeadSha: string; closeSourceBranch?: boolean; commitMessage?: string; - } & BitbucketMutationInput, + } & BitbucketMutationInput ): Promise { const access = await targetAccess(target); try { const pr = BitbucketPullRequestWriteSchema.parse( - await requestBitbucketJson(access, prPath(access, target.prId)), + await requestBitbucketJson(access, prPath(access, target.prId)) ); - if (pr.state === "MERGED") { + 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.", - ); + if (pr.state !== 'OPEN') { + throw new BitbucketReviewError('bad_request', 'The pull request is closed.'); } await requestBitbucketJson(access, `${prPath(access, target.prId)}/merge`, { - method: "POST", + method: 'POST', body: { close_source_branch: target.closeSourceBranch ?? false, - ...(target.commitMessage - ? { commit_message: target.commitMessage } - : {}), + ...(target.commitMessage ? { commit_message: target.commitMessage } : {}), }, }); return { done: true, replayed: 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 index 1fb96358fc..a293b4b376 100644 --- a/apps/web/src/lib/provider-review/gitlab-write.test.ts +++ b/apps/web/src/lib/provider-review/gitlab-write.test.ts @@ -1,7 +1,7 @@ -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 { 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, @@ -16,37 +16,34 @@ import { resolveThread, submitReview, unresolveThread, -} from "./gitlab-write"; +} 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", () => ({ +jest.mock('@/lib/integrations/db/platform-integrations', () => ({ getIntegrationForOwner: (owner: Owner, platform: string) => mockGetIntegrationForOwner(owner, platform), })); -jest.mock("@/lib/integrations/gitlab-service", () => ({ +jest.mock('@/lib/integrations/gitlab-service', () => ({ getValidGitLabToken: (integration: PlatformIntegration, actor: unknown) => mockGetValidGitLabToken(integration, actor), })); -jest.mock("@/lib/integrations/platforms/gitlab/adapter", () => ({ +jest.mock('@/lib/integrations/platforms/gitlab/adapter', () => ({ createMRNote: (...args: unknown[]) => mockCreateMRNote(...args), - fetchGitLabMergeRequest: (params: unknown) => - mockFetchGitLabMergeRequest(params), + 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", - ); +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 @@ -57,56 +54,52 @@ jest.mock("@/lib/integrations/platforms/gitlab/instance-url", () => { }; }); -const OWNER: { type: "user"; userId: string } = { - type: "user", - userId: "user_1", +const OWNER: { type: 'user'; userId: string } = { + type: 'user', + userId: 'user_1', }; -const INSTANCE_URL = "https://gitlab.example.com"; -const PROJECT_PATH = "group/sub/repo"; +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 { +async function captureRejection(promise: Promise): Promise { try { await promise; } catch (reason) { return reason as GitLabReviewError; } - throw new Error("Expected the call to reject."); + 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", + 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 }, - ], + 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", + title: 'Add nested deploy script', description: null, - state: "opened", + state: 'opened', draft: false, - source_branch: "feature/deploy", - target_branch: "main", + source_branch: 'feature/deploy', + target_branch: 'main', sha: headSha, diff_refs: { - base_sha: "sha-base", + base_sha: 'sha-base', head_sha: headSha, - start_sha: "sha-start", + start_sha: 'sha-start', }, web_url: `${INSTANCE_URL}/group/sub/repo/-/merge_requests/12`, - author: { id: 1, username: "alice", name: "Alice" }, + author: { id: 1, username: 'alice', name: 'Alice' }, ...extra, }; } @@ -116,7 +109,7 @@ let fetchMock: jest.Mock; function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, - headers: { "content-type": "application/json" }, + headers: { 'content-type': 'application/json' }, }); } @@ -131,215 +124,223 @@ function lastRequest(): { url: URL; init: RequestInit } { beforeEach(() => { jest.clearAllMocks(); mockGetIntegrationForOwner.mockResolvedValue(integrationRow); - mockGetValidGitLabToken.mockResolvedValue("glpat-mock-token"); + mockGetValidGitLabToken.mockResolvedValue('glpat-mock-token'); mockCreateMRNote.mockResolvedValue(undefined); - mockFetchGitLabMergeRequest.mockResolvedValue(openMrFixture("sha-head")); - fetchMock = jest.fn().mockResolvedValue(jsonResponse({ state: "opened" })); + 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 () => { +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", + body: 'Ship it', + operationKey: 'op-1', }); expect(result).toEqual({ done: true, replayed: false }); expect(mockCreateMRNote).toHaveBeenCalledWith( - "glpat-mock-token", + 'glpat-mock-token', PROJECT_PATH, 12, - "Ship it", - INSTANCE_URL, + 'Ship it', + INSTANCE_URL ); }); - it("without an anchor keeps the note path and fetches no diff refs", async () => { - await addComment({ ...TARGET, body: "Ship it" }); + 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 () => { + it('replies inside a discussion thread', async () => { const result = await replyToDiscussion({ ...TARGET, - discussionId: "disc-1", - body: "Fixed", - operationKey: "op-2", + 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(init.method).toBe('POST'); expect(url.pathname).toBe( - `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/discussions/disc-1/notes`, + `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/discussions/disc-1/notes` ); - expect(JSON.parse(String(init.body))).toEqual({ body: "Fixed" }); + expect(JSON.parse(String(init.body))).toEqual({ body: 'Fixed' }); }); }); -describe("addComment with an anchor (diff discussion)", () => { +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, + entry => new URL(String(entry[0])).pathname === discussionsPath ); - if (!call) throw new Error("Expected a POST to the discussions endpoint."); + 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 () => { + 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 }, + 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(init.method).toBe('POST'); + expect(url.pathname).toBe(discussionsPath); expect(JSON.parse(String(init.body))).toEqual({ - body: "Guard the path", + 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", + 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 () => { + 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 }, + 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; + 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", + 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 adds old_line beside new_line", async () => { + 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 }, + 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; - expect(position.new_line).toBe(20); - expect(position.old_line).toBe(10); + 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 () => { + it('refuses an anchor when the MR reports no diff refs, before any write', async () => { mockFetchGitLabMergeRequest.mockResolvedValue({ - ...openMrFixture("sha-head"), + ...openMrFixture('sha-head'), diff_refs: undefined, }); const error = await captureRejection( addComment({ ...TARGET, - body: "x", - anchor: { path: "a.ts", side: "RIGHT", line: 1 }, - }), + body: 'x', + anchor: { path: 'a.ts', side: 'RIGHT', line: 1 }, + }) ); - expect(error.kind).toBe("bad_request"); + 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), - ); + 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 }, - }), + body: 'x', + anchor: { path: 'a.ts', side: 'RIGHT', line: 999_999 }, + }) ); expect(error).toBeInstanceOf(GitLabReviewError); - expect(error.kind).toBe("bad_request"); + expect(error.kind).toBe('bad_request'); expect(error.retryable).toBe(false); }); }); -describe("submitReview", () => { - it("approve posts the approval plus an optional summary note", async () => { +describe('submitReview', () => { + it('approve posts the approval plus an optional summary note', async () => { const result = await submitReview({ ...TARGET, - event: "approve", - body: "LGTM", + 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`, + `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/approve` ); - expect(init.method).toBe("POST"); + expect(init.method).toBe('POST'); expect(mockCreateMRNote).toHaveBeenCalledWith( - "glpat-mock-token", + 'glpat-mock-token', PROJECT_PATH, 12, - "LGTM", - INSTANCE_URL, + 'LGTM', + INSTANCE_URL ); }); - it("approve without a body posts no note", async () => { - await submitReview({ ...TARGET, event: "approve" }); + 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" }); + 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 () => { + 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" }), + submitReview({ ...TARGET, event: 'request_changes', body: 'Nope' }) ); expect(error).toBeInstanceOf(GitLabReviewError); - expect(error.kind).toBe("bad_request"); + 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: @@ -347,156 +348,207 @@ describe("submitReview", () => { 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", - ); + 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", () => { +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" }); + 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"); + 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))); + .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 () => { + 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", + event: 'approve', + body: 'LGTM', comments: [ - { path: "a.ts", side: "RIGHT", line: 3, body: "first inline" }, + { path: 'a.ts', side: 'RIGHT', line: 3, body: 'first inline' }, { - path: "b.ts", - side: "LEFT", + path: 'b.ts', + side: 'LEFT', line: 9, startLine: 4, - body: "second inline", + body: 'second inline', }, ], }); expect(result).toEqual({ done: true, replayed: false }); - expect(order).toEqual(["discussion", "discussion", "approve", "note"]); + expect(order).toEqual(['discussion', 'discussion', 'approve', 'note']); expect(discussionBodies()).toEqual([ { - body: "first inline", + 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", + 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", + 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", + 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 () => { + 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" }], + 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(order).toEqual(['discussion']); expect(mockCreateMRNote).not.toHaveBeenCalled(); }); - it("a mid-batch provider rejection surfaces the classified error and stops the batch", async () => { + 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")) { + if (path.endsWith('/discussions')) { discussions += 1; return discussions === 1 - ? jsonResponse({ id: "disc-1" }) - : jsonResponse({ message: "400 line is not in diff" }, 400); + ? jsonResponse({ id: 'disc-1' }) + : jsonResponse({ message: '400 line is not in diff' }, 400); } - return jsonResponse({ state: "opened" }); + return jsonResponse({ state: 'opened' }); }); const error = await captureRejection( submitReview({ ...TARGET, - event: "approve", - body: "LGTM", + event: 'approve', + body: 'LGTM', comments: [ - { path: "a.ts", side: "RIGHT", line: 3, body: "first" }, - { path: "b.ts", side: "RIGHT", line: 4, body: "outside" }, + { path: 'a.ts', side: 'RIGHT', line: 3, body: 'first' }, + { path: 'b.ts', side: 'RIGHT', line: 4, body: 'outside' }, ], - }), + }) ); - expect(error.kind).toBe("bad_request"); - expect(error.retryable).toBe(false); + // 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"), - ), + 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", () => { +describe('resolveThread / unresolveThread', () => { function discussionFixture(resolved: boolean) { return { - id: "disc-1", + id: 'disc-1', individual_note: false, notes: [ { id: 11, - body: "Guard this", - author: { id: 1, username: "alice", name: "Alice" }, - created_at: "", - updated_at: "", + body: 'Guard this', + author: { id: 1, username: 'alice', name: 'Alice' }, + created_at: '', + updated_at: '', system: false, noteable_id: 100, - noteable_type: "MergeRequest", + noteable_type: 'MergeRequest', noteable_iid: 12, resolvable: true, resolved, @@ -505,250 +557,242 @@ describe("resolveThread / unresolveThread", () => { }; } - it("PUTs the discussion resolved flag", async () => { + it('PUTs the discussion resolved flag', async () => { fetchMock .mockResolvedValueOnce(jsonResponse(discussionFixture(false))) .mockResolvedValueOnce(jsonResponse(discussionFixture(true))); - const result = await resolveThread({ ...TARGET, discussionId: "disc-1" }); + 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(init.method).toBe('PUT'); expect(url.pathname).toBe( - `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/discussions/disc-1`, + `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/discussions/disc-1` ); - expect(url.searchParams.get("resolved")).toBe("true"); + expect(url.searchParams.get('resolved')).toBe('true'); }); - it("reports replayed without a write when the thread is already resolved", async () => { + 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" }); + 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 () => { + it('unresolveThread clears the flag', async () => { fetchMock .mockResolvedValueOnce(jsonResponse(discussionFixture(true))) .mockResolvedValueOnce(jsonResponse(discussionFixture(false))); - const result = await unresolveThread({ ...TARGET, discussionId: "disc-1" }); + const result = await unresolveThread({ ...TARGET, discussionId: 'disc-1' }); expect(result).toEqual({ done: true, replayed: false }); - expect(lastRequest().url.searchParams.get("resolved")).toBe("false"); + expect(lastRequest().url.searchParams.get('resolved')).toBe('false'); }); - it("refuses to resolve a non-resolvable discussion", async () => { + it('refuses to resolve a non-resolvable discussion', async () => { fetchMock.mockResolvedValue( jsonResponse({ - id: "disc-9", + id: 'disc-9', individual_note: true, notes: [ { id: 20, - body: "note", - author: { id: 1, username: "alice", name: "Alice" }, - created_at: "", - updated_at: "", + body: 'note', + author: { id: 1, username: 'alice', name: 'Alice' }, + created_at: '', + updated_at: '', system: false, noteable_id: 100, - noteable_type: "MergeRequest", + noteable_type: 'MergeRequest', noteable_iid: 12, resolvable: false, }, ], - }), + }) ); - await expect( - resolveThread({ ...TARGET, discussionId: "disc-9" }), - ).rejects.toMatchObject({ - kind: "bad_request", + 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 () => { +describe('mergePullRequest', () => { + it('re-fetches the MR, fences the head, and merges the exact revision', async () => { const result = await mergePullRequest({ ...TARGET, - expectedHeadSha: "sha-head", + expectedHeadSha: 'sha-head', squash: true, shouldRemoveSourceBranch: true, - operationKey: "op-merge", + operationKey: 'op-merge', }); expect(result).toEqual({ done: true, replayed: false }); expect(mockFetchGitLabMergeRequest).toHaveBeenCalled(); const { url, init } = lastRequest(); - expect(init.method).toBe("PUT"); + expect(init.method).toBe('PUT'); expect(url.pathname).toBe( - `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/merge`, + `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/merge` ); expect(JSON.parse(String(init.body))).toEqual({ - sha: "sha-head", + 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")); + 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" }), + mergePullRequest({ ...TARGET, expectedHeadSha: 'sha-head' }) ); expect(error).toBeInstanceOf(GitLabReviewError); - expect(error.kind).toBe("stale_head"); + 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"), - ); + 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 () => { + it('reports replayed when the MR is already merged', async () => { mockFetchGitLabMergeRequest.mockResolvedValue({ - ...openMrFixture("sha-head"), - state: "merged", + ...openMrFixture('sha-head'), + state: 'merged', }); const result = await mergePullRequest({ ...TARGET, - expectedHeadSha: "sha-head", + 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 () => { + it('refuses a closed MR with a non-retryable bad_request', async () => { mockFetchGitLabMergeRequest.mockResolvedValue({ - ...openMrFixture("sha-head"), - state: "closed", + ...openMrFixture('sha-head'), + state: 'closed', }); await expect( - mergePullRequest({ ...TARGET, expectedHeadSha: "sha-head" }), - ).rejects.toMatchObject({ kind: "bad_request", retryable: false }); + 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), - ); + 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" }), + mergePullRequest({ ...TARGET, expectedHeadSha: 'sha-head' }) ); - expect(error.kind).toBe("stale_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.", + '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 () => { +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" } }), + openMrFixture('sha-head', { head_pipeline: { status: 'running' } }) ); const result = await enableAutoMerge({ ...TARGET, - expectedHeadSha: "sha-head", + expectedHeadSha: 'sha-head', }); expect(result).toEqual({ done: true, replayed: false }); const { url, init } = lastRequest(); - expect(init.method).toBe("PUT"); + 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`, + `/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", + sha: 'sha-head', }); }); - it("reports replayed when auto-merge is already enabled", async () => { + it('reports replayed when auto-merge is already enabled', async () => { mockFetchGitLabMergeRequest.mockResolvedValue( - openMrFixture("sha-head", { merge_when_pipeline_succeeds: true }), + openMrFixture('sha-head', { merge_when_pipeline_succeeds: true }) ); const result = await enableAutoMerge({ ...TARGET, - expectedHeadSha: "sha-head", + expectedHeadSha: 'sha-head', }); expect(result).toEqual({ done: true, replayed: true }); expect(fetchMock).not.toHaveBeenCalled(); }); - it("refuses a stale head with the exact reason and never arms", async () => { + it('refuses a stale head with the exact reason and never arms', async () => { mockFetchGitLabMergeRequest.mockResolvedValue( - openMrFixture("sha-moved", { head_pipeline: { status: "running" } }), + openMrFixture('sha-moved', { head_pipeline: { status: 'running' } }) ); const error = await captureRejection( - enableAutoMerge({ ...TARGET, expectedHeadSha: "sha-head" }), + enableAutoMerge({ ...TARGET, expectedHeadSha: 'sha-head' }) ); - expect(error.kind).toBe("stale_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 () => { + it('refuses an MR with no pipeline instead of letting GitLab merge immediately', async () => { mockFetchGitLabMergeRequest.mockResolvedValue( - openMrFixture("sha-head", { head_pipeline: null }), + openMrFixture('sha-head', { head_pipeline: null }) ); const error = await captureRejection( - enableAutoMerge({ ...TARGET, expectedHeadSha: "sha-head" }), + enableAutoMerge({ ...TARGET, expectedHeadSha: 'sha-head' }) ); expect(error).toBeInstanceOf(GitLabReviewError); - expect(error.kind).toBe("bad_request"); + 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"), - ); + const mergeCall = fetchMock.mock.calls.find(call => String(call[0]).endsWith('/merge')); expect(mergeCall).toBeUndefined(); }); - it("refuses when the latest pipeline already finished", async () => { + it('refuses when the latest pipeline already finished', async () => { mockFetchGitLabMergeRequest.mockResolvedValue( - openMrFixture("sha-head", { head_pipeline: { status: "success" } }), + 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, - }); + 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 () => { +describe('disableAutoMerge', () => { + it('cancels through the dedicated cancel endpoint, not the update endpoint', async () => { mockFetchGitLabMergeRequest.mockResolvedValue( - openMrFixture("sha-head", { merge_when_pipeline_succeeds: true }), + openMrFixture('sha-head', { merge_when_pipeline_succeeds: true }) ); const result = await disableAutoMerge({ ...TARGET }); @@ -757,98 +801,91 @@ describe("disableAutoMerge", () => { 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(init.method).toBe('POST'); expect(url.pathname).toBe( - `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/merge_requests/12/cancel_merge_when_pipeline_succeeds`, + `/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 () => { + 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 () => { + it('fences a stale head when the caller provides one', async () => { mockFetchGitLabMergeRequest.mockResolvedValue( - openMrFixture("sha-moved", { merge_when_pipeline_succeeds: true }), + openMrFixture('sha-moved', { merge_when_pipeline_succeeds: true }) ); await expect( - disableAutoMerge({ ...TARGET, expectedHeadSha: "sha-head" }), - ).rejects.toMatchObject({ kind: "stale_head" }); + disableAutoMerge({ ...TARGET, expectedHeadSha: 'sha-head' }) + ).rejects.toMatchObject({ kind: 'stale_head' }); expect(fetchMock).not.toHaveBeenCalled(); }); }); -describe("deleteBranch", () => { - it("deletes the project branch", async () => { +describe('deleteBranch', () => { + it('deletes the project branch', async () => { fetchMock.mockResolvedValue(new Response(null, { status: 204 })); const result = await deleteBranch({ ...TARGET, - branchName: "feature/deploy", + branchName: 'feature/deploy', }); expect(result).toEqual({ done: true, replayed: false }); const { url, init } = lastRequest(); - expect(init.method).toBe("DELETE"); + expect(init.method).toBe('DELETE'); expect(url.pathname).toBe( - `/api/v4/projects/${encodeURIComponent(PROJECT_PATH)}/repository/branches/${encodeURIComponent("feature/deploy")}`, + `/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), - ); + 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", + 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 () => { +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, - ), + jsonResponse({ message: '403 Forbidden — token glpat-secret denied' }, 403) ); - const error = await captureRejection( - submitReview({ ...TARGET, event: "approve" }), - ); + const error = await captureRejection(submitReview({ ...TARGET, event: 'approve' })); - expect(error.kind).toBe("forbidden"); + 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"); + 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)); + 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 }); + 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")); + 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" }), + replyToDiscussion({ ...TARGET, discussionId: 'd', body: 'x' }) ); - expect(error.kind).toBe("retryable"); + 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 index bd4024cc19..4c3bebb5a1 100644 --- a/apps/web/src/lib/provider-review/gitlab-write.ts +++ b/apps/web/src/lib/provider-review/gitlab-write.ts @@ -10,27 +10,27 @@ * effect. `operationKey` is accepted for that ledger; this layer performs no * ledger writes itself. */ -import "server-only"; +import 'server-only'; import type { ProviderReviewCapabilities, ProviderReviewInlineAnchor, ProviderReviewInlineComment, -} from "@kilocode/app-shared/provider-review"; +} from '@kilocode/app-shared/provider-review'; import { createMRNote, fetchGitLabMergeRequest, type GitLabDiscussion, type GitLabMergeRequest, -} from "@/lib/integrations/platforms/gitlab/adapter"; +} from '@/lib/integrations/platforms/gitlab/adapter'; import { authorizeProject, classifyGitLabError, GitLabReviewError, type GitLabProjectAccess, type GitLabReviewOwner, -} from "./gitlab-authorization"; -import { requestGitLabJson } from "./gitlab-read"; +} from './gitlab-authorization'; +import { requestGitLabJson } from './gitlab-read'; /** The MR a write acts on. `instanceHint` is display/matching only. */ export type GitLabMrTarget = { @@ -54,14 +54,14 @@ export type GitLabMutationResult = { * 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."; + '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 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 @@ -70,7 +70,7 @@ export const GITLAB_STALE_HEAD_REASON = * 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."; + '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 @@ -80,12 +80,12 @@ export const GITLAB_AUTO_MERGE_NO_PIPELINE_REASON = */ export const GITLAB_MR_REVIEW_CAPABILITIES: ProviderReviewCapabilities = { canComment: true, - reviewEvents: ["approve", "comment"], + reviewEvents: ['approve', 'comment'], canResolveThreads: true, canMerge: true, - autoMerge: { supported: true, reason: "" }, - reactions: { supported: true, reason: "" }, - reviewStatus: { supported: true, reason: "" }, + autoMerge: { supported: true, reason: '' }, + reactions: { supported: true, reason: '' }, + reviewStatus: { supported: true, reason: '' }, }; type GitLabMergeRequestDetail = GitLabMergeRequest & { @@ -100,31 +100,25 @@ type GitLabMergeRequestDetail = GitLabMergeRequest & { * immediately instead of waiting, so auto-merge cannot be armed on it. */ const GITLAB_ACTIVE_PIPELINE_STATUSES = new Set([ - "created", - "waiting_for_resources", - "waiting", - "pending", - "running", - "scheduled", - "preparing", - "completing", + 'created', + 'waiting_for_resources', + 'waiting', + 'pending', + 'running', + 'scheduled', + 'preparing', + 'completing', ]); function hasActivePipeline(mr: GitLabMergeRequestDetail): boolean { return ( - typeof mr.head_pipeline?.status === "string" && + 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, - ); +async function targetAccess(target: GitLabMrTarget): Promise { + return authorizeProject(target.owner, target.projectPath, target.instanceHint); } function mrPath(access: GitLabProjectAccess, mrIid: number): string { @@ -140,7 +134,7 @@ type GitLabDiffRefs = { base_sha: string; head_sha: string; start_sha: string }; async function fetchMrDiffRefs( access: GitLabProjectAccess, - mrIid: number, + mrIid: number ): Promise { const mr = (await fetchGitLabMergeRequest({ accessToken: access.accessToken, @@ -151,8 +145,8 @@ async function fetchMrDiffRefs( 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.", + 'bad_request', + 'The merge request has no diff positions to anchor a comment to.' ); } return refs; @@ -161,45 +155,85 @@ async function fetchMrDiffRefs( /** * 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 adds `old_line` to a RIGHT anchor. + * 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, + anchor: ProviderReviewInlineAnchor ): Record { const position: Record = { - position_type: "text", + 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") { + if (anchor.side === 'RIGHT') { position.new_line = anchor.line; - if (anchor.startLine !== undefined) position.old_line = anchor.startLine; } 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. + * 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 }>, + anchored: Array<{ anchor: ProviderReviewInlineAnchor; body: string }> ): Promise { const refs = await fetchMrDiffRefs(access, mrIid); + let committed = false; for (const item of anchored) { - await requestGitLabJson(access, `${mrPath(access, mrIid)}/discussions`, { - method: "POST", - body: { body: item.body, position: buildTextPosition(refs, item.anchor) }, - }); + 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; } } @@ -212,7 +246,7 @@ export async function addComment( target: GitLabMrTarget & { body: string; anchor?: ProviderReviewInlineAnchor; - } & GitLabMutationInput, + } & GitLabMutationInput ): Promise { const access = await targetAccess(target); try { @@ -226,7 +260,7 @@ export async function addComment( access.projectPath, target.mrIid, target.body, - access.instanceUrl, + access.instanceUrl ); } return { done: true, replayed: false }; @@ -240,14 +274,14 @@ export async function replyToDiscussion( target: GitLabMrTarget & { discussionId: string; body: string; - } & GitLabMutationInput, + } & 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 } }, + { method: 'POST', body: { body: target.body } } ); return { done: true, replayed: false }; } catch (error) { @@ -260,50 +294,46 @@ export async function replyToDiscussion( * `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; a - * mid-batch failure throws the classified error and the router's - * reconcile-ambiguous handling keeps the ledger from replaying a duplicate. + * 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"; + event: 'approve' | 'comment' | 'request_changes'; body?: string; comments?: ProviderReviewInlineComment[]; - } & GitLabMutationInput, + } & GitLabMutationInput ): Promise { - if (target.event === "request_changes") { - throw new GitLabReviewError( - "bad_request", - GITLAB_REQUEST_CHANGES_UNSUPPORTED_REASON, - ); + 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) => ({ + 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.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, + access.instanceUrl ); } } else if (target.body) { @@ -312,17 +342,14 @@ export async function submitReview( access.projectPath, target.mrIid, target.body, - access.instanceUrl, + access.instanceUrl ); } else if (!target.comments?.length) { - throw new GitLabReviewError( - "bad_request", - "A comment review needs a body.", - ); + throw new GitLabReviewError('bad_request', 'A comment review needs a body.'); } return { done: true, replayed: false }; } catch (error) { - throw classifyGitLabError(error); + throw classifySubmitFailure(error, inlineCommitted); } } @@ -330,13 +357,13 @@ export async function submitReview( async function fetchDiscussionResolvedState( access: GitLabProjectAccess, mrIid: number, - discussionId: string, + discussionId: string ): Promise<{ resolved: boolean; resolvable: boolean }> { const discussion = await requestGitLabJson( access, - `${mrPath(access, mrIid)}/discussions/${encodeURIComponent(discussionId)}`, + `${mrPath(access, mrIid)}/discussions/${encodeURIComponent(discussionId)}` ); - const resolvableNote = discussion?.notes?.find((note) => note.resolvable); + const resolvableNote = discussion?.notes?.find(note => note.resolvable); return { resolvable: Boolean(resolvableNote), resolved: resolvableNote?.resolved === true, @@ -345,20 +372,13 @@ async function fetchDiscussionResolvedState( async function setThreadResolved( target: GitLabMrTarget & { discussionId: string } & GitLabMutationInput, - resolved: boolean, + resolved: boolean ): Promise { const access = await targetAccess(target); try { - const state = await fetchDiscussionResolvedState( - access, - target.mrIid, - target.discussionId, - ); + const state = await fetchDiscussionResolvedState(access, target.mrIid, target.discussionId); if (!state.resolvable) { - throw new GitLabReviewError( - "bad_request", - "This discussion cannot be resolved on GitLab.", - ); + throw new GitLabReviewError('bad_request', 'This discussion cannot be resolved on GitLab.'); } if (state.resolved === resolved) { return { done: true, replayed: true }; @@ -366,7 +386,7 @@ async function setThreadResolved( await requestGitLabJson( access, `${mrPath(access, target.mrIid)}/discussions/${encodeURIComponent(target.discussionId)}`, - { method: "PUT", query: { resolved } }, + { method: 'PUT', query: { resolved } } ); return { done: true, replayed: false }; } catch (error) { @@ -376,14 +396,14 @@ async function setThreadResolved( /** Resolve a discussion thread (PUT discussions). */ export async function resolveThread( - target: GitLabMrTarget & { discussionId: string } & GitLabMutationInput, + 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, + target: GitLabMrTarget & { discussionId: string } & GitLabMutationInput ): Promise { return setThreadResolved(target, false); } @@ -393,13 +413,10 @@ export async function unresolveThread( * 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 { +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); + throw new GitLabReviewError('stale_head', GITLAB_STALE_HEAD_REASON); } } @@ -415,7 +432,7 @@ export async function mergePullRequest( shouldRemoveSourceBranch?: boolean; commitTitle?: string; commitMessage?: string; - } & GitLabMutationInput, + } & GitLabMutationInput ): Promise { const access = await targetAccess(target); try { @@ -425,31 +442,24 @@ export async function mergePullRequest( mrIid: target.mrIid, instanceUrl: access.instanceUrl, })) as GitLabMergeRequestDetail; - if (mr.state === "merged") { + 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.", - ); + 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", + 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 } - : {}), + ...(target.commitTitle ? { merge_commit_title: target.commitTitle } : {}), + ...(target.commitMessage ? { merge_commit_message: target.commitMessage } : {}), }, }); return { done: true, replayed: false }; @@ -468,7 +478,7 @@ export async function mergePullRequest( * immediately in that state. Already-armed reports `replayed`. */ export async function enableAutoMerge( - target: GitLabMrTarget & { expectedHeadSha: string } & GitLabMutationInput, + target: GitLabMrTarget & { expectedHeadSha: string } & GitLabMutationInput ): Promise { const access = await targetAccess(target); try { @@ -483,13 +493,10 @@ export async function enableAutoMerge( } requireHeadShaFence(mr, target.expectedHeadSha); if (!hasActivePipeline(mr)) { - throw new GitLabReviewError( - "bad_request", - GITLAB_AUTO_MERGE_NO_PIPELINE_REASON, - ); + throw new GitLabReviewError('bad_request', GITLAB_AUTO_MERGE_NO_PIPELINE_REASON); } await requestGitLabJson(access, `${mrPath(access, target.mrIid)}/merge`, { - method: "PUT", + method: 'PUT', body: { merge_when_pipeline_succeeds: true, sha: target.expectedHeadSha }, }); return { done: true, replayed: false }; @@ -507,7 +514,7 @@ export async function enableAutoMerge( * not required here. */ export async function disableAutoMerge( - target: GitLabMrTarget & { expectedHeadSha?: string } & GitLabMutationInput, + target: GitLabMrTarget & { expectedHeadSha?: string } & GitLabMutationInput ): Promise { const access = await targetAccess(target); try { @@ -526,7 +533,7 @@ export async function disableAutoMerge( await requestGitLabJson( access, `${mrPath(access, target.mrIid)}/cancel_merge_when_pipeline_succeeds`, - { method: "POST" }, + { method: 'POST' } ); return { done: true, replayed: false }; } catch (error) { @@ -539,18 +546,18 @@ export async function disableAutoMerge( * state already, so it reports `replayed` rather than an error. */ export async function deleteBranch( - target: GitLabMrTarget & { branchName: string } & GitLabMutationInput, + 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" }, + { method: 'DELETE' } ); return { done: true, replayed: false }; } catch (error) { - if (error instanceof GitLabReviewError && error.kind === "not_found") { + if (error instanceof GitLabReviewError && error.kind === 'not_found') { return { done: true, replayed: true }; } throw error; diff --git a/apps/web/src/routers/provider-review-router.test.ts b/apps/web/src/routers/provider-review-router.test.ts index fde109b7ca..b54e69abcd 100644 --- a/apps/web/src/routers/provider-review-router.test.ts +++ b/apps/web/src/routers/provider-review-router.test.ts @@ -1,29 +1,26 @@ /** * @jest-environment node */ -import { describe, expect, it, beforeAll, beforeEach } from "@jest/globals"; +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 { 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"; +} 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"; +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 @@ -38,26 +35,23 @@ const mockSettleOperation = jest.fn(); const mockMarkReconcilePending = jest.fn(); const mockRecordOperationAcceptance = jest.fn(); -jest.mock("@kilocode/db/operation-ledger", () => ({ +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), + markReconcilePending: (...args: unknown[]) => mockMarkReconcilePending(...args), + recordOperationAcceptance: (...args: unknown[]) => mockRecordOperationAcceptance(...args), })); // The router passes `db` to the (mocked) ledger only. -jest.mock("@/lib/drizzle", () => ({ db: {} })); +jest.mock('@/lib/drizzle', () => ({ db: {} })); const mockEnsureOrganizationAccess = jest.fn(); -jest.mock("./organizations/utils", () => ({ - ensureOrganizationAccess: (...args: unknown[]) => - mockEnsureOrganizationAccess(...args), +jest.mock('./organizations/utils', () => ({ + ensureOrganizationAccess: (...args: unknown[]) => mockEnsureOrganizationAccess(...args), })); const mockAssertTermsAccepted = jest.fn(); -jest.mock("./github-pr-review-router", () => ({ +jest.mock('./github-pr-review-router', () => ({ assertTermsAccepted: (...args: unknown[]) => mockAssertTermsAccepted(...args), })); @@ -72,7 +66,7 @@ const gitlabRead = { listInbox: jest.fn(), getMergeState: jest.fn(), }; -jest.mock("@/lib/provider-review/gitlab-read", () => ({ +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), @@ -92,15 +86,14 @@ const bitbucketRead = { listInbox: jest.fn(), getMergeRestrictions: jest.fn(), }; -jest.mock("@/lib/provider-review/bitbucket-read", () => ({ +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), + getMergeRestrictions: (...a: unknown[]) => bitbucketRead.getMergeRestrictions(...a), requestBitbucketJson: jest.fn(), fetchPage: jest.fn(), repositoryPathGuard: jest.fn(), @@ -118,8 +111,8 @@ const gitlabWrite = { enableAutoMerge: jest.fn(), disableAutoMerge: jest.fn(), }; -jest.mock("@/lib/provider-review/gitlab-write", () => ({ - ...jest.requireActual("@/lib/provider-review/gitlab-write"), +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), @@ -138,8 +131,8 @@ const bitbucketWrite = { unresolveThread: jest.fn(), mergePullRequest: jest.fn(), }; -jest.mock("@/lib/provider-review/bitbucket-write", () => ({ - ...jest.requireActual("@/lib/provider-review/bitbucket-write"), +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), @@ -151,26 +144,24 @@ jest.mock("@/lib/provider-review/bitbucket-write", () => ({ // ----- fixtures --------------------------------------------------------------- const gitlabBase = { - platform: "gitlab" as const, - projectPath: "group/sub/repo", + platform: 'gitlab' as const, + projectPath: 'group/sub/repo', mrIid: 7, }; const bitbucketBase = { - platform: "bitbucket" as const, + platform: 'bitbucket' as const, organizationId: ORG_ID, - workspace: "acme", - repoSlug: "widgets", + workspace: 'acme', + repoSlug: 'widgets', prId: 12, }; -function admittedRow( - overrides: Partial = {}, -): OperationLedgerRow { +function admittedRow(overrides: Partial = {}): OperationLedgerRow { return { - id: "row-1", - intent: "create_review_comment", - resource_key: "resource-key-under-test", - status: "admitted", + id: 'row-1', + intent: 'create_review_comment', + resource_key: 'resource-key-under-test', + status: 'admitted', canonical_result: null, ...overrides, } as OperationLedgerRow; @@ -182,27 +173,22 @@ function admittedRow( * 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 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), + ref: { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 7 }, + state: 'open', + headSha: 'a'.repeat(40), ...overrides, }; } @@ -217,18 +203,16 @@ beforeAll(() => { beforeEach(() => { jest.clearAllMocks(); - mockEnsureOrganizationAccess.mockResolvedValue("member"); + 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", + admission: 'admitted', row: admittedRow({ intent: args.intent, resource_key: args.resourceKey }), })); mockSettleOperation.mockResolvedValue({ settled: true, row: admittedRow() }); - mockMarkReconcilePending.mockResolvedValue( - admittedRow({ status: "reconcile_pending" }), - ); + mockMarkReconcilePending.mockResolvedValue(admittedRow({ status: 'reconcile_pending' })); mockRecordOperationAcceptance.mockResolvedValue(null); gitlabRead.getMergeRequest.mockResolvedValue(summaryFixture()); gitlabWrite.addComment.mockResolvedValue({ done: true, replayed: false }); @@ -249,119 +233,117 @@ beforeEach(() => { // ----- 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 () => { +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" }, + { instanceUrl: 'https://evil.example' }, + { token: 'glpat-secret' }, + { host: 'evil.example' }, + { userId: 'victim' }, ]) { - await expect( - caller.getPullRequest({ ...gitlabBase, ...smuggled }), - ).rejects.toMatchObject({ - code: "BAD_REQUEST", + await expect(caller.getPullRequest({ ...gitlabBase, ...smuggled })).rejects.toMatchObject({ + code: 'BAD_REQUEST', }); } expect(gitlabRead.getMergeRequest).not.toHaveBeenCalled(); }); - it("requires organizationId on the Bitbucket arm", async () => { + it('requires organizationId on the Bitbucket arm', async () => { await expect( caller.getPullRequest({ - platform: "bitbucket", - workspace: "acme", - repoSlug: "widgets", + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'widgets', prId: 12, - }), - ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); expect(bitbucketRead.getPullRequest).not.toHaveBeenCalled(); }); - it("accepts the infinite-query direction discriminator on paged inputs", async () => { + 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" }), + caller.listFiles({ ...gitlabBase, cursor: 'c1', direction: 'forward' }) ).resolves.toBeDefined(); expect(gitlabRead.listChangedFiles).toHaveBeenCalledWith( - { type: "user", userId: USER_ID }, - "group/sub/repo", + { type: 'user', userId: USER_ID }, + 'group/sub/repo', 7, - "c1", - undefined, + 'c1', + undefined ); }); }); // ----- identity is server-derived ---------------------------------------------- -describe("providerReviewRouter identity derivation", () => { - it("runs ensureOrganizationAccess before any provider call when an organizationId is present", async () => { +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, + ORG_ID ); expect(gitlabRead.getMergeRequest).toHaveBeenCalledWith( - { type: "organization", organizationId: ORG_ID, userId: USER_ID }, - "group/sub/repo", + { type: 'organization', organizationId: ORG_ID, userId: USER_ID }, + 'group/sub/repo', 7, - undefined, + undefined ); }); - it("derives the personal owner from ctx.user, never from input", async () => { + 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", + { type: 'user', userId: USER_ID }, + 'group/sub/repo', 7, - undefined, + undefined ); }); - it("stops before any provider call when the organization guard rejects", async () => { + it('stops before any provider call when the organization guard rejects', async () => { mockEnsureOrganizationAccess.mockRejectedValueOnce( - new TRPCError({ code: "FORBIDDEN", message: "no access" }), + new TRPCError({ code: 'FORBIDDEN', message: 'no access' }) ); await expect( caller.addComment({ ...bitbucketBase, - body: "hi", - operationKey: "key-1", - }), - ).rejects.toMatchObject({ code: "FORBIDDEN" }); + 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 () => { + 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", + 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", + { type: 'user', userId: USER_ID }, + 'group/sub/repo', 7, - "gitlab.example", + 'gitlab.example' ); }); - it("never lets a page cursor steer which repository is read", async () => { + it('never lets a page cursor steer which repository is read', async () => { gitlabRead.listChangedFiles.mockResolvedValue({ items: [], nextCursor: null, @@ -373,225 +355,217 @@ describe("providerReviewRouter identity derivation", () => { ...gitlabBase, cursor: Buffer.from( JSON.stringify({ - identity: "gitlab-diff:other/repo#1", - next: "https://x/other%2Frepo", - }), - ).toString("base64url"), + 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", + { type: 'user', userId: USER_ID }, + 'group/sub/repo', 7, expect.any(String), - undefined, + undefined ); }); }); // ----- the shared operation ledger ------------------------------------------------ -describe("providerReviewRouter ledger", () => { - it("admits provider writes into the shared pr domain with a provider-tagged resource key", async () => { +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", + body: 'hello', + operationKey: 'key-1', }); const expectedKey = providerLedgerResourceKey( - "create_review_comment", - { platform: "gitlab", projectPath: "group/sub/repo", mrIid: 7 }, + 'create_review_comment', + { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 7 }, { - platform: "gitlab", - projectPath: "group/sub/repo", + platform: 'gitlab', + projectPath: 'group/sub/repo', instanceHint: undefined, number: 7, - body: "hello", - }, + body: 'hello', + } ); expect(mockAdmitOperation).toHaveBeenCalledWith( {}, expect.objectContaining({ userId: USER_ID, - domain: "pr", - intent: "create_review_comment", - operationKey: "key-1", - taxonomy: "reconcile-first", + 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); + 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", () => { + 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 }, + 'create_review_comment', + { platform: 'gitlab', projectPath: 'octocat/hello', mrIid: 1 }, { - platform: "gitlab", - projectPath: "octocat/hello", + platform: 'gitlab', + projectPath: 'octocat/hello', number: 1, - body: "same text", - }, + body: 'same text', + } ); // The GitHub ledger identity (prLedgerResourceKey) is // `owner/repo#number::hash` — a plain string prefix. - const githubStyle = "octocat/hello#1::"; + const githubStyle = 'octocat/hello#1::'; expect(gitlabKey.startsWith(githubStyle)).toBe(false); expect( gitlabKey.startsWith( providerPrRefKey({ - platform: "gitlab", - projectPath: "octocat/hello", + platform: 'gitlab', + projectPath: 'octocat/hello', mrIid: 1, - }), - ), + }) + ) ).toBe(true); const bitbucketKey = providerLedgerResourceKey( - "create_review_comment", + 'create_review_comment', { - platform: "bitbucket", - workspace: "octocat", - repoSlug: "hello", + platform: 'bitbucket', + workspace: 'octocat', + repoSlug: 'hello', prId: 1, }, { - platform: "bitbucket", - workspace: "octocat", - repoSlug: "hello", + platform: 'bitbucket', + workspace: 'octocat', + repoSlug: 'hello', number: 1, - body: "same text", - }, + 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 () => { + it('settles a completed write with the pr_operation_settled outbox event', async () => { await caller.addComment({ ...gitlabBase, - body: "hello", - operationKey: "key-1", + body: 'hello', + operationKey: 'key-1', }); expect(mockSettleOperation).toHaveBeenCalledWith( {}, expect.objectContaining({ - rowId: "row-1", - status: "completed", - outcomeCode: "ok", + 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"); + 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", + 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", + 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", - }), + 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 () => { + it('refuses a key reused for a different intent with no effect and no replay', async () => { mockAdmitOperation.mockResolvedValueOnce({ - admission: "admitted", - row: admittedRow({ intent: "merge" }), + admission: 'admitted', + row: admittedRow({ intent: 'merge' }), }); await expect( caller.addComment({ ...gitlabBase, - body: "hello", - operationKey: "key-1", - }), + body: 'hello', + operationKey: 'key-1', + }) ).rejects.toMatchObject({ - code: "CONFLICT", - message: "operation_key_reuse_mismatch", + 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"); + it('never re-executes an in-flight duplicate', async () => { + admittingOnce('duplicate_in_flight'); await expect( caller.addComment({ ...gitlabBase, - body: "hello", - operationKey: "key-1", - }), + body: 'hello', + operationKey: 'key-1', + }) ).rejects.toMatchObject({ - code: "CONFLICT", - message: "operation_in_progress", + code: 'CONFLICT', + message: 'operation_in_progress', }); expect(gitlabWrite.addComment).not.toHaveBeenCalled(); }); - it("runs the UGC terms gate before admission", async () => { + it('runs the UGC terms gate before admission', async () => { mockAssertTermsAccepted.mockRejectedValueOnce( - new TRPCError({ code: "PRECONDITION_FAILED", message: "terms_required" }), + new TRPCError({ code: 'PRECONDITION_FAILED', message: 'terms_required' }) ); await expect( caller.addComment({ ...gitlabBase, - body: "hello", - operationKey: "key-1", - }), + body: 'hello', + operationKey: 'key-1', + }) ).rejects.toMatchObject({ - code: "PRECONDITION_FAILED", - message: "terms_required", + 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 () => { + 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.", - ), + new GitLabReviewError('retryable', 'Could not reach GitLab. Please try again.') ); await expect( caller.addComment({ ...gitlabBase, - body: "hello", - operationKey: "key-1", - }), + body: 'hello', + operationKey: 'key-1', + }) ).rejects.toMatchObject({ - code: "CONFLICT", + code: 'CONFLICT', message: "Couldn't confirm — check the merge request before retrying.", }); expect(mockMarkReconcilePending).toHaveBeenCalledWith( {}, - expect.objectContaining({ rowId: "row-1" }), + 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" }); + 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); }); @@ -599,393 +573,377 @@ describe("providerReviewRouter ledger", () => { // ----- inline anchors ------------------------------------------------------------- -describe("providerReviewRouter inline anchors", () => { +describe('providerReviewRouter inline anchors', () => { const anchor = { - path: "src/a.ts", - side: "RIGHT" as const, + 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 () => { + it('passes the anchor to the GitLab write and folds it into the fingerprint', async () => { await caller.addComment({ ...gitlabBase, - body: "inline", + body: 'inline', anchor, - operationKey: "key-1", + operationKey: 'key-1', }); expect(gitlabWrite.addComment).toHaveBeenCalledWith( - expect.objectContaining({ body: "inline", anchor }), + expect.objectContaining({ body: 'inline', anchor }) ); const expectedKey = providerLedgerResourceKey( - "create_review_comment", - { platform: "gitlab", projectPath: "group/sub/repo", mrIid: 7 }, + 'create_review_comment', + { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 7 }, { - platform: "gitlab", - projectPath: "group/sub/repo", + platform: 'gitlab', + projectPath: 'group/sub/repo', instanceHint: undefined, number: 7, - body: "inline", - path: "src/a.ts", + body: 'inline', + path: 'src/a.ts', line: 42, - side: "RIGHT", + side: 'RIGHT', startLine: 40, - }, + } ); expect(mockAdmitOperation).toHaveBeenCalledWith( {}, - expect.objectContaining({ resourceKey: expectedKey }), + expect.objectContaining({ resourceKey: expectedKey }) ); }); - it("passes the anchor to the Bitbucket write and folds it into the fingerprint", async () => { + it('passes the anchor to the Bitbucket write and folds it into the fingerprint', async () => { await caller.addComment({ ...bitbucketBase, - body: "inline", + body: 'inline', anchor, - operationKey: "key-1", + operationKey: 'key-1', }); expect(bitbucketWrite.addComment).toHaveBeenCalledWith( - expect.objectContaining({ body: "inline", anchor }), + expect.objectContaining({ body: 'inline', anchor }) ); const expectedKey = providerLedgerResourceKey( - "create_review_comment", + 'create_review_comment', { - platform: "bitbucket", - workspace: "acme", - repoSlug: "widgets", + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'widgets', prId: 12, }, { - platform: "bitbucket", - workspace: "acme", - repoSlug: "widgets", + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'widgets', number: 12, - body: "inline", - path: "src/a.ts", + body: 'inline', + path: 'src/a.ts', line: 42, - side: "RIGHT", + side: 'RIGHT', startLine: 40, - }, + } ); expect(mockAdmitOperation).toHaveBeenCalledWith( {}, - expect.objectContaining({ resourceKey: expectedKey }), + expect.objectContaining({ resourceKey: expectedKey }) ); }); - it("an anchored and an unanchored comment with the same body never share a ledger key", async () => { + 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 }, + 'create_review_comment', + { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 7 }, { - platform: "gitlab", - projectPath: "group/sub/repo", + platform: 'gitlab', + projectPath: 'group/sub/repo', number: 7, - body: "same", - path: "src/a.ts", + body: 'same', + path: 'src/a.ts', line: 42, - side: "RIGHT", - }, + side: 'RIGHT', + } ); const plain = providerLedgerResourceKey( - "create_review_comment", - { platform: "gitlab", projectPath: "group/sub/repo", mrIid: 7 }, + 'create_review_comment', + { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 7 }, { - platform: "gitlab", - projectPath: "group/sub/repo", + platform: 'gitlab', + projectPath: 'group/sub/repo', number: 7, - body: "same", - }, + body: 'same', + } ); expect(anchored).not.toEqual(plain); }); - it("without an anchor the write payload and the fingerprint bytes stay unchanged", async () => { + it('without an anchor the write payload and the fingerprint bytes stay unchanged', async () => { await caller.addComment({ ...gitlabBase, - body: "hello", - operationKey: "key-1", + body: 'hello', + operationKey: 'key-1', }); - expect(gitlabWrite.addComment).toHaveBeenCalledWith( - expect.objectContaining({ body: "hello" }), - ); - expect(gitlabWrite.addComment.mock.calls[0][0]).not.toHaveProperty( - "anchor", - ); + 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 }, + 'create_review_comment', + { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 7 }, { - platform: "gitlab", - projectPath: "group/sub/repo", + platform: 'gitlab', + projectPath: 'group/sub/repo', instanceHint: undefined, number: 7, - body: "hello", - }, + body: 'hello', + } ); expect(mockAdmitOperation).toHaveBeenCalledWith( {}, - expect.objectContaining({ resourceKey: legacyKey }), + expect.objectContaining({ resourceKey: legacyKey }) ); }); - it("refuses malformed anchors with BAD_REQUEST before any write", async () => { + 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 }, + { 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", + body: 'x', anchor: bad, - operationKey: "k", - }), - ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + 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 () => { + 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: 'a.ts', side: 'RIGHT' as const, line: 3, body: 'first' }, { - path: "b.ts", - side: "LEFT" as const, + path: 'b.ts', + side: 'LEFT' as const, line: 9, startLine: 4, - body: "second", + body: 'second', }, ]; await caller.submitReview({ ...gitlabBase, - event: "approve", - body: "LGTM", + event: 'approve', + body: 'LGTM', comments, - operationKey: "key-1", + operationKey: 'key-1', }); - expect(gitlabWrite.submitReview).toHaveBeenCalledWith( - expect.objectContaining({ comments }), - ); + expect(gitlabWrite.submitReview).toHaveBeenCalledWith(expect.objectContaining({ comments })); const expectedKey = providerLedgerResourceKey( - "submit_review", - { platform: "gitlab", projectPath: "group/sub/repo", mrIid: 7 }, + 'submit_review', + { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 7 }, { - platform: "gitlab", - projectPath: "group/sub/repo", + platform: 'gitlab', + projectPath: 'group/sub/repo', instanceHint: undefined, number: 7, - event: "approve", - body: "LGTM", + event: 'approve', + body: 'LGTM', comments, - }, + } ); expect(mockAdmitOperation).toHaveBeenCalledWith( {}, - expect.objectContaining({ resourceKey: expectedKey }), + expect.objectContaining({ resourceKey: expectedKey }) ); }); - it("Bitbucket submitReview carries the batch through the same path", async () => { + 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" }, - ]; + const comments = [{ path: 'a.ts', side: 'RIGHT' as const, line: 3, body: 'first' }]; await caller.submitReview({ ...bitbucketBase, - event: "comment", + event: 'comment', comments, - operationKey: "key-1", + operationKey: 'key-1', }); expect(bitbucketWrite.submitReview).toHaveBeenCalledWith( - expect.objectContaining({ event: "comment", comments }), + expect.objectContaining({ event: 'comment', comments }) ); }); - it("a submit without comments keeps the legacy fingerprint bytes", async () => { + 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", + event: 'approve', + body: 'LGTM', + operationKey: 'key-1', }); - expect(gitlabWrite.submitReview.mock.calls[0][0]).not.toHaveProperty( - "comments", - ); + expect(gitlabWrite.submitReview.mock.calls[0][0]).not.toHaveProperty('comments'); const legacyKey = providerLedgerResourceKey( - "submit_review", - { platform: "gitlab", projectPath: "group/sub/repo", mrIid: 7 }, + 'submit_review', + { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 7 }, { - platform: "gitlab", - projectPath: "group/sub/repo", + platform: 'gitlab', + projectPath: 'group/sub/repo', instanceHint: undefined, number: 7, - event: "approve", - body: "LGTM", - }, + event: 'approve', + body: 'LGTM', + } ); expect(mockAdmitOperation).toHaveBeenCalledWith( {}, - expect.objectContaining({ resourceKey: legacyKey }), + expect.objectContaining({ resourceKey: legacyKey }) ); }); - it("refuses comment items and oversized batches with BAD_REQUEST before any write", async () => { + 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" }); + event: 'comment', + comments: [{ path: 'a.ts', side: 'RIGHT', line: 1 }], + operationKey: 'k', + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); await expect( caller.submitReview({ ...gitlabBase, - event: "comment", + event: 'comment', comments: Array.from({ length: 101 }, (_, i) => ({ - path: "a.ts", - side: "RIGHT" as const, + path: 'a.ts', + side: 'RIGHT' as const, line: i + 1, - body: "x", + body: 'x', })), - operationKey: "k", - }), - ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + 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 () => { +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), + new GitLabReviewError('stale_head', GITLAB_STALE_HEAD_REASON) ); await expect( caller.mergePullRequest({ ...gitlabBase, - expectedHeadSha: "a".repeat(40), - operationKey: "key-merge", - }), + expectedHeadSha: 'a'.repeat(40), + operationKey: 'key-merge', + }) ).rejects.toMatchObject({ - code: "CONFLICT", + code: 'CONFLICT', message: GITLAB_STALE_HEAD_REASON, }); expect(mockSettleOperation).toHaveBeenCalledWith( {}, - expect.objectContaining({ status: "failed", outcomeCode: "head_moved" }), + 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", + 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" }), - ); + gitlabRead.getMergeRequest.mockResolvedValueOnce(summaryFixture({ state: 'merged' })); await expect( caller.mergePullRequest({ ...gitlabBase, - expectedHeadSha: "a".repeat(40), - operationKey: "key-merge", - }), + 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", + { type: 'user', userId: USER_ID }, + 'group/sub/repo', 7, - undefined, + undefined ); expect(gitlabWrite.mergePullRequest).not.toHaveBeenCalled(); expect(mockSettleOperation).toHaveBeenCalledWith( {}, expect.objectContaining({ - status: "completed", + 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", + 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) }), - ); + gitlabRead.getMergeRequest.mockResolvedValueOnce(summaryFixture({ headSha: 'b'.repeat(40) })); await expect( caller.mergePullRequest({ ...gitlabBase, - expectedHeadSha: "a".repeat(40), - operationKey: "key-merge", - }), + expectedHeadSha: 'a'.repeat(40), + operationKey: 'key-merge', + }) ).rejects.toMatchObject({ - code: "CONFLICT", + code: 'CONFLICT', message: GITLAB_STALE_HEAD_REASON, }); expect(gitlabWrite.mergePullRequest).not.toHaveBeenCalled(); expect(mockSettleOperation).toHaveBeenCalledWith( {}, expect.objectContaining({ - status: "failed", - outcomeCode: "head_moved", + status: 'failed', + outcomeCode: 'head_moved', outboxEvent: expect.objectContaining({ properties: expect.objectContaining({ - reconcile_result: "confirmed_absent", + reconcile_result: 'confirmed_absent', }), }), - }), + }) ); }); - it("a failed authoritative read stays reconcile-pending instead of settling absent", async () => { - admittingOnce("duplicate_reconcile_pending", { - status: "reconcile_pending", + 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"), - ); + gitlabRead.getMergeRequest.mockRejectedValueOnce(new GitLabReviewError('not_found', 'gone')); await expect( caller.mergePullRequest({ ...gitlabBase, - expectedHeadSha: "a".repeat(40), - operationKey: "key-merge", - }), - ).rejects.toMatchObject({ code: "CONFLICT" }); + expectedHeadSha: 'a'.repeat(40), + operationKey: 'key-merge', + }) + ).rejects.toMatchObject({ code: 'CONFLICT' }); expect(mockMarkReconcilePending).toHaveBeenCalled(); expect(mockSettleOperation).not.toHaveBeenCalled(); }); @@ -993,19 +951,17 @@ describe("providerReviewRouter merge head fence", () => { // ----- 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", +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 () => { + it('answers Bitbucket with the shared capability list carrying the auto-merge reason', async () => { await expect( - caller.getCapabilities({ platform: "bitbucket", organizationId: ORG_ID }), + caller.getCapabilities({ platform: 'bitbucket', organizationId: ORG_ID }) ).resolves.toEqual(BITBUCKET_PR_REVIEW_CAPABILITIES); expect(BITBUCKET_PR_REVIEW_CAPABILITIES.autoMerge).toMatchObject({ supported: false, @@ -1013,13 +969,13 @@ describe("providerReviewRouter capabilities", () => { }); }); - it("returns the capability reason for Bitbucket auto-merge without a ledger row", async () => { + 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", - }), + expectedHeadSha: 'a'.repeat(40), + operationKey: 'key-am', + }) ).resolves.toEqual({ supported: false, reason: BITBUCKET_AUTO_MERGE_UNSUPPORTED_REASON, @@ -1028,35 +984,33 @@ describe("providerReviewRouter capabilities", () => { }); expect(mockAdmitOperation).not.toHaveBeenCalled(); expect(mockEnsureOrganizationAccess).toHaveBeenCalled(); - await expect( - caller.disableAutoMerge({ ...bitbucketBase }), - ).resolves.toMatchObject({ + 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 () => { + 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", - }), + expectedHeadSha: 'a'.repeat(40), + operationKey: 'key-am', + }) ).resolves.toEqual({ supported: true, - reason: "", + reason: '', done: true, replayed: false, }); expect(mockAdmitOperation).toHaveBeenCalledWith( {}, - expect.objectContaining({ intent: "enable_auto_merge" }), + expect.objectContaining({ intent: 'enable_auto_merge' }) ); - await caller.disableAutoMerge({ ...gitlabBase, operationKey: "key-dam" }); + await caller.disableAutoMerge({ ...gitlabBase, operationKey: 'key-dam' }); expect(mockAdmitOperation).toHaveBeenCalledWith( {}, - expect.objectContaining({ intent: "disable_auto_merge" }), + 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 index df1e663960..ffd556f782 100644 --- a/apps/web/src/routers/provider-review-router.ts +++ b/apps/web/src/routers/provider-review-router.ts @@ -16,43 +16,36 @@ * 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 'server-only'; -import * as z from "zod"; -import { createHash } from "node:crypto"; -import { TRPCError } from "@trpc/server"; +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 { 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"; +} 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"; +} 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, @@ -63,8 +56,8 @@ import { resolveThread as gitlabResolveThread, submitReview as gitlabSubmitReview, unresolveThread as gitlabUnresolveThread, -} from "@/lib/provider-review/gitlab-write"; -import * as bitbucketRead from "@/lib/provider-review/bitbucket-read"; +} 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, @@ -74,9 +67,9 @@ import { 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"; +} 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 ---------------------------------------------------------- @@ -91,7 +84,7 @@ const bitbucketSlugRegex = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; // 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 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 @@ -105,7 +98,7 @@ const operationKeySchema = z.string().min(1).max(128).optional(); // 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"]), + side: z.enum(['LEFT', 'RIGHT']), line: z.number().int().positive(), startLine: z.number().int().positive().optional(), }; @@ -118,20 +111,20 @@ const providerInlineAnchorInput = z .object(inlineAnchorShape) .strict() .refine( - (value) => value.startLine === undefined || value.startLine <= value.line, - startLineOrderIssue, + 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, + value => value.startLine === undefined || value.startLine <= value.line, + startLineOrderIssue ); const gitlabIdentityShape = { - platform: z.literal("gitlab"), + platform: z.literal('gitlab'), organizationId: z.uuid().optional(), projectPath: z.string().regex(gitlabProjectPathRegex).max(1024), mrIid: z.number().int().positive(), @@ -141,7 +134,7 @@ const gitlabIdentityShape = { }; const bitbucketIdentityShape = { - platform: z.literal("bitbucket"), + platform: z.literal('bitbucket'), // Bitbucket Cloud is organization-context only: the id is required and the // org guard always runs. organizationId: z.uuid(), @@ -152,24 +145,22 @@ const bitbucketIdentityShape = { /** One provider-discriminated PR/MR ref input, `.strict()` on both arms. */ function providerRefInput(extra: T) { - return z.discriminatedUnion("platform", [ + 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", [ +const providerIdentityInput = z.discriminatedUnion('platform', [ z .object({ - platform: z.literal("gitlab"), + 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(), + z.object({ platform: z.literal('bitbucket'), organizationId: z.uuid() }).strict(), ]); const GetPullRequestInput = providerRefInput({}); @@ -186,10 +177,10 @@ const ListDiscussionsInput = providerRefInput({ const ListChecksInput = providerRefInput({}); -const ListInboxInput = z.discriminatedUnion("platform", [ +const ListInboxInput = z.discriminatedUnion('platform', [ z .object({ - platform: z.literal("gitlab"), + platform: z.literal('gitlab'), organizationId: z.uuid().optional(), instanceHint: z.string().min(1).max(2048).optional(), cursor: pageCursor, @@ -198,7 +189,7 @@ const ListInboxInput = z.discriminatedUnion("platform", [ .strict(), z .object({ - platform: z.literal("bitbucket"), + platform: z.literal('bitbucket'), organizationId: z.uuid(), cursor: pageCursor, direction: infiniteQueryDirection, @@ -228,7 +219,7 @@ const AddCommentInput = providerRefInput({ operationKey: operationKeySchema, }); -const ReplyToCommentInput = z.discriminatedUnion("platform", [ +const ReplyToCommentInput = z.discriminatedUnion('platform', [ // GitLab replies land inside a discussion; the discussion id is the thread. z .object({ @@ -250,7 +241,7 @@ const ReplyToCommentInput = z.discriminatedUnion("platform", [ ]); const SubmitReviewInput = providerRefInput({ - event: z.enum(["approve", "request_changes", "comment"]), + 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 @@ -259,7 +250,7 @@ const SubmitReviewInput = providerRefInput({ operationKey: operationKeySchema, }); -const ResolveThreadInput = z.discriminatedUnion("platform", [ +const ResolveThreadInput = z.discriminatedUnion('platform', [ z .object({ ...gitlabIdentityShape, @@ -276,7 +267,7 @@ const ResolveThreadInput = z.discriminatedUnion("platform", [ .strict(), ]); -const MergePullRequestInput = z.discriminatedUnion("platform", [ +const MergePullRequestInput = z.discriminatedUnion('platform', [ z .object({ ...gitlabIdentityShape, @@ -322,26 +313,26 @@ const DisableAutoMergeInput = providerRefInput({ */ async function gitlabOwner( ctx: TRPCContext, - input: { organizationId?: string }, + input: { organizationId?: string } ): Promise { if (input.organizationId) { await ensureOrganizationAccess(ctx, input.organizationId); return { - type: "organization", + type: 'organization', organizationId: input.organizationId, userId: ctx.user.id, }; } - return { type: "user", userId: ctx.user.id }; + return { type: 'user', userId: ctx.user.id }; } async function bitbucketOwner( ctx: TRPCContext, - input: { organizationId: string }, + input: { organizationId: string } ): Promise { await ensureOrganizationAccess(ctx, input.organizationId); return { - type: "organization", + type: 'organization', organizationId: input.organizationId, userId: ctx.user.id, }; @@ -356,16 +347,16 @@ function providerRef(input: { repoSlug?: string; prId?: number; }): ProviderPrRef { - if (input.platform === "gitlab") { + if (input.platform === 'gitlab') { return { - platform: "gitlab", + platform: 'gitlab', projectPath: String(input.projectPath), mrIid: Number(input.mrIid), instanceHint: input.instanceHint, }; } return { - platform: "bitbucket", + platform: 'bitbucket', workspace: String(input.workspace), repoSlug: String(input.repoSlug), prId: Number(input.prId), @@ -381,26 +372,23 @@ function providerRef(input: { */ function toProviderTrpcError(error: unknown): TRPCError { if (error instanceof TRPCError) return error; - if ( - error instanceof GitLabReviewError || - error instanceof BitbucketReviewError - ) { + 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 }); + 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.", + code: 'INTERNAL_SERVER_ERROR', + message: 'The review request failed. Please try again.', }); } @@ -420,21 +408,20 @@ async function providerCall(work: () => Promise): Promise { // 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_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."; +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 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."; + 'We could not record this action. Please try again later.'; /** * The provider ledger resource identity: the s1 canonical ref key (platform @@ -446,11 +433,11 @@ const PROVIDER_LEDGER_PERSISTENCE_FAILED_MESSAGE = export function providerLedgerResourceKey( intent: PrLedgerIntent, ref: ProviderPrRef, - fingerprintInput: Record, + fingerprintInput: Record ): string { - const fingerprint = createHash("sha256") + const fingerprint = createHash('sha256') .update(prIntentFingerprint(intent, fingerprintInput)) - .digest("hex") + .digest('hex') .slice(0, 16); return `${providerPrRefKey(ref)}::${fingerprint}`; } @@ -462,10 +449,10 @@ export function providerLedgerResourceKey( */ function gitlabFingerprintInput( input: { projectPath: string; mrIid: number; instanceHint?: string }, - fields: Record, + fields: Record ): Record { return { - platform: "gitlab", + platform: 'gitlab', projectPath: input.projectPath, instanceHint: input.instanceHint, number: input.mrIid, @@ -475,10 +462,10 @@ function gitlabFingerprintInput( function bitbucketFingerprintInput( input: { workspace: string; repoSlug: string; prId: number }, - fields: Record, + fields: Record ): Record { return { - platform: "bitbucket", + platform: 'bitbucket', workspace: input.workspace, repoSlug: input.repoSlug, number: input.prId, @@ -488,7 +475,7 @@ function bitbucketFingerprintInput( function ambiguousProviderError(platform: ProviderPrPlatform): TRPCError { return new TRPCError({ - code: "CONFLICT", + code: 'CONFLICT', message: `Couldn't confirm — check the ${providerPrTerm(platform)} before retrying.`, }); } @@ -498,14 +485,12 @@ function ambiguousProviderError(platform: ProviderPrPlatform): TRPCError { * 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 { +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)}`, + `Failed to write provider PR operation ledger row: ${error instanceof Error ? error.message : String(error)}` ); } } @@ -514,22 +499,20 @@ async function bestEffortLedgerWrite( function providerSettledOutboxEvent(params: { distinctId: string; intent: PrLedgerIntent; - outcome: "completed" | "failed" | "ambiguous"; - reconcileResult?: "confirmed_completed" | "confirmed_absent" | "unresolved"; + 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", + source: 'web', + surface: 'pr', + phase: 'terminal', intent: params.intent, outcome: params.outcome, - ...(params.reconcileResult !== undefined - ? { reconcile_result: params.reconcileResult } - : {}), + ...(params.reconcileResult !== undefined ? { reconcile_result: params.reconcileResult } : {}), duration_ms: Math.max(0, Date.now() - params.startedAt), }, }; @@ -557,18 +540,18 @@ async function settleCompletedProviderRow( base: ProviderLedgerBase, row: OperationLedgerRow, canonicalResult: Record, - reconcileResult?: "confirmed_completed", + reconcileResult?: 'confirmed_completed' ): Promise { try { await settleOperation(db, { rowId: row.id, - status: "completed", - outcomeCode: "ok", + status: 'completed', + outcomeCode: 'ok', canonicalResult, outboxEvent: providerSettledOutboxEvent({ distinctId: base.distinctId, intent: base.intent, - outcome: "completed", + outcome: 'completed', reconcileResult, startedAt: base.startedAt, }), @@ -581,13 +564,13 @@ async function settleCompletedProviderRow( rowId: row.id, providerRef: null, canonicalResult, - }), + }) ); console.error( - `Failed to settle completed provider PR operation ledger row: ${error instanceof Error ? error.message : String(error)}`, + `Failed to settle completed provider PR operation ledger row: ${error instanceof Error ? error.message : String(error)}` ); throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", + code: 'INTERNAL_SERVER_ERROR', message: PROVIDER_LEDGER_SETTLE_FAILED_MESSAGE, cause: error, }); @@ -599,21 +582,21 @@ async function settleFailedProviderRow( base: ProviderLedgerBase, row: OperationLedgerRow, outcomeCode: string, - reconcileResult?: "confirmed_absent", + reconcileResult?: 'confirmed_absent' ): Promise { await bestEffortLedgerWrite(() => settleOperation(db, { rowId: row.id, - status: "failed", + status: 'failed', outcomeCode, outboxEvent: providerSettledOutboxEvent({ distinctId: base.distinctId, intent: base.intent, - outcome: "failed", + outcome: 'failed', reconcileResult, startedAt: base.startedAt, }), - }), + }) ); } @@ -625,7 +608,7 @@ async function settleFailedProviderRow( */ async function failProviderRowAmbiguous( base: ProviderLedgerBase, - row: OperationLedgerRow, + row: OperationLedgerRow ): Promise { try { const updated = await markReconcilePending(db, { @@ -633,22 +616,20 @@ async function failProviderRowAmbiguous( outboxEvent: providerSettledOutboxEvent({ distinctId: base.distinctId, intent: base.intent, - outcome: "ambiguous", - reconcileResult: "unresolved", + outcome: 'ambiguous', + reconcileResult: 'unresolved', startedAt: base.startedAt, }), }); - if (!updated || updated.status !== "reconcile_pending") { - throw new Error( - "markReconcilePending did not leave the row reconcile_pending", - ); + 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)}`, + `Failed to mark provider PR operation ledger row reconcile-pending: ${error instanceof Error ? error.message : String(error)}` ); throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", + code: 'INTERNAL_SERVER_ERROR', message: PROVIDER_LEDGER_PERSISTENCE_FAILED_MESSAGE, cause: error, }); @@ -664,25 +645,22 @@ async function failProviderRowAmbiguous( * write path. */ function outcomeCodeFromFailure(error: unknown, trpcError: TRPCError): string { - if ( - error instanceof GitLabReviewError || - error instanceof BitbucketReviewError - ) { - if (error.kind === "stale_head") return "head_moved"; + 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"; + 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"; + return 'bad_request'; } } @@ -693,8 +671,8 @@ function outcomeCodeFromFailure(error: unknown, trpcError: TRPCError): string { * 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"; + if (error.code === 'BAD_GATEWAY') return true; + return intent === 'merge' && error.code === 'NOT_FOUND'; } /** @@ -705,7 +683,7 @@ function isAmbiguousFailure(error: TRPCError, intent: PrLedgerIntent): boolean { async function executeProviderWrite>( base: ProviderLedgerBase, row: OperationLedgerRow, - write: () => Promise, + write: () => Promise ): Promise { let canonical: T; try { @@ -715,11 +693,7 @@ async function executeProviderWrite>( if (isAmbiguousFailure(trpcError, base.intent)) { return failProviderRowAmbiguous(base, row); } - await settleFailedProviderRow( - base, - row, - outcomeCodeFromFailure(error, trpcError), - ); + await settleFailedProviderRow(base, row, outcomeCodeFromFailure(error, trpcError)); throw trpcError; } // The write committed: settle completed at the committed-effect boundary. @@ -728,10 +702,8 @@ async function executeProviderWrite>( } /** 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") { +function replaySettledProviderRow(row: OperationLedgerRow): ReplayedResult { + if (row.status === 'completed' || row.status === 'no_op') { return { ...(row.canonical_result ?? {}), replayed: true, @@ -740,7 +712,7 @@ function replaySettledProviderRow( // 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", + code: 'BAD_REQUEST', message: PROVIDER_REPLAY_FAILED_MESSAGE, }); } @@ -773,7 +745,7 @@ type ProviderLedgerMutationArgs = ProviderLedgerBase & { * fingerprint); a mismatch refuses the key reuse with no effect and no replay. */ async function runProviderLedgerMutation( - args: ProviderLedgerMutationArgs, + args: ProviderLedgerMutationArgs ): Promise> { const admission = await admitOperation(db, { userId: args.userId, @@ -781,33 +753,30 @@ async function runProviderLedgerMutation( intent: args.intent, operationKey: args.operationKey, resourceKey: args.resourceKey, - taxonomy: "reconcile-first", + taxonomy: 'reconcile-first', leaseSeconds: PROVIDER_LEDGER_LEASE_SECONDS, }); - if ( - admission.row.intent !== args.intent || - admission.row.resource_key !== args.resourceKey - ) { + if (admission.row.intent !== args.intent || admission.row.resource_key !== args.resourceKey) { throw new TRPCError({ - code: "CONFLICT", + code: 'CONFLICT', message: OPERATION_KEY_REUSE_MISMATCH_MESSAGE, }); } switch (admission.admission) { - case "admitted": + case 'admitted': return args.execute(admission.row); - case "duplicate_settled": + case 'duplicate_settled': return replaySettledProviderRow(admission.row); - case "duplicate_in_flight": - case "duplicate_reconcile_in_progress": + case 'duplicate_in_flight': + case 'duplicate_reconcile_in_progress': throw new TRPCError({ - code: "CONFLICT", + code: 'CONFLICT', message: OPERATION_IN_PROGRESS_MESSAGE, }); - case "takeover": - case "duplicate_reconcile_pending": + case 'takeover': + case 'duplicate_reconcile_pending': return args.reconcile(admission.row); } } @@ -838,21 +807,14 @@ async function runProviderMutation>(args: { 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); + 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, + reconcile: args.reconcileAmbiguous ? row => failProviderRowAmbiguous(base, row) : execute, }); } @@ -875,23 +837,23 @@ async function reconcileMergeProviderRow>( /** 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" }; + | { 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" }; + 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" }; + ? { 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 @@ -899,34 +861,29 @@ async function reconcileMergeProviderRow>( } switch (state.kind) { - case "merged": { + case 'merged': { const canonical = { done: true, replayed: true }; - await settleCompletedProviderRow( - base, - row, - canonical, - "confirmed_completed", - ); + await settleCompletedProviderRow(base, row, canonical, 'confirmed_completed'); return { ...canonical, replayed: true } as unknown as ReplayedResult; } - case "closed": - case "stale_head": + case 'closed': + case 'stale_head': await settleFailedProviderRow( base, row, - state.kind === "closed" ? "already_closed" : "head_moved", - "confirmed_absent", + state.kind === 'closed' ? 'already_closed' : 'head_moved', + 'confirmed_absent' ); throw new TRPCError({ - code: "CONFLICT", + code: 'CONFLICT', message: - state.kind === "stale_head" + 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": + case 'lineage_intact': return executeProviderWrite(base, row, args.execute); - case "unresolved": + case 'unresolved': return failProviderRowAmbiguous(base, row); } } @@ -934,313 +891,261 @@ async function reconcileMergeProviderRow>( // ----- 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); + getPullRequest: baseProcedure.input(GetPullRequestInput).query(async ({ ctx, input }) => { + if (input.platform === 'gitlab') { + const owner = await gitlabOwner(ctx, input); return providerCall(() => - bitbucketRead.getPullRequest( - owner, - input.workspace, - input.repoSlug, - input.prId, - ), + gitlabRead.getMergeRequest(owner, input.projectPath, input.mrIid, input.instanceHint) ); - }), - - 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); + } + 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(() => - bitbucketRead.listChecks( - owner, - input.workspace, - input.repoSlug, - input.prId, - ), + gitlabRead.listChecks(owner, input.projectPath, input.mrIid, input.instanceHint) ); - }), - - 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); + } + 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(() => - bitbucketRead.listChangedFiles( + gitlabRead.listChangedFiles( owner, - input.workspace, - input.repoSlug, - input.prId, + input.projectPath, + input.mrIid, input.cursor, - ), + input.instanceHint + ) ); - }), - - 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); + } + 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(() => - bitbucketRead.getFileLines( + gitlabRead.getFileLines( owner, - input.workspace, - input.repoSlug, + input.projectPath, input.ref, input.path, input.startLine, input.endLine, - ), + input.instanceHint + ) ); - }), - - 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); + } + 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(() => - bitbucketRead.listDiscussions( + gitlabRead.listDiscussions( owner, - input.workspace, - input.repoSlug, - input.prId, + 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)); - }), + 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); + 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(() => - bitbucketRead.getMergeRestrictions( - owner, - input.workspace, - input.repoSlug, - input.prId, - ), + 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); + 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: bitbucketFingerprintInput(input, { + intent: 'create_review_comment', + fingerprintInput: gitlabFingerprintInput(input, { body: input.body, ...anchorFields, }), operationKey: input.operationKey, write: () => - bitbucketAddComment({ + gitlabAddComment({ owner, - workspace: input.workspace, - repoSlug: input.repoSlug, - prId: input.prId, + 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); + 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: bitbucketFingerprintInput(input, { - commentId: input.commentId, + intent: 'reply_comment', + fingerprintInput: gitlabFingerprintInput(input, { + commentId: input.discussionId, body: input.body, }), operationKey: input.operationKey, write: () => - bitbucketReplyToComment({ + gitlabReplyToComment({ owner, - workspace: input.workspace, - repoSlug: input.repoSlug, - prId: input.prId, - commentId: input.commentId, + 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 @@ -1248,151 +1153,145 @@ export const providerReviewRouter = createTRPCRouter({ * 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); + 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: bitbucketFingerprintInput(input, { + intent: 'submit_review', + fingerprintInput: gitlabFingerprintInput(input, { event: input.event, body: input.body, comments: input.comments, }), operationKey: input.operationKey, write: () => - bitbucketSubmitReview({ + gitlabSubmitReview({ owner, - workspace: input.workspace, - repoSlug: input.repoSlug, - prId: input.prId, + projectPath: input.projectPath, + mrIid: input.mrIid, + instanceHint: input.instanceHint, 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); + } + 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: bitbucketFingerprintInput(input, { - threadId: input.threadId, + intent: 'resolve_thread', + fingerprintInput: gitlabFingerprintInput(input, { + threadId: input.discussionId, }), operationKey: input.operationKey, write: () => - bitbucketResolveThread({ + gitlabResolveThread({ owner, - workspace: input.workspace, - repoSlug: input.repoSlug, - prId: input.prId, - threadId: input.threadId, + 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, }); - }), - - 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); + } + 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: bitbucketFingerprintInput(input, { - threadId: input.threadId, + intent: 'unresolve_thread', + fingerprintInput: gitlabFingerprintInput(input, { + threadId: input.discussionId, }), operationKey: input.operationKey, write: () => - bitbucketUnresolveThread({ + gitlabUnresolveThread({ owner, - workspace: input.workspace, - repoSlug: input.repoSlug, - prId: input.prId, - threadId: input.threadId, + 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 @@ -1400,123 +1299,104 @@ export const providerReviewRouter = createTRPCRouter({ * 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, + 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.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); + if (input.platform === 'gitlab') { + const owner = await gitlabOwner(ctx, input); const write = () => - bitbucketMerge({ + gitlabMerge({ owner, - workspace: input.workspace, - repoSlug: input.repoSlug, - prId: input.prId, + projectPath: input.projectPath, + mrIid: input.mrIid, + instanceHint: input.instanceHint, expectedHeadSha: mergeFields.expectedHeadSha, - closeSourceBranch: mergeFields.deleteBranch, + 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); + const execute = (row: OperationLedgerRow) => executeProviderWrite(base, row, write); return runProviderLedgerMutation({ ...base, operationKey: input.operationKey, - resourceKey: providerLedgerResourceKey("merge", ref, fingerprintInput), + resourceKey: providerLedgerResourceKey('merge', ref, fingerprintInput), execute, - reconcile: (row) => + reconcile: row => reconcileMergeProviderRow(base, row, { expectedHeadSha: input.expectedHeadSha, - // Owner-bound authoritative read — same identity as the write. + // 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: () => - bitbucketRead.getPullRequest( - owner, - input.workspace, - input.repoSlug, - input.prId, - ), + 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 @@ -1524,74 +1404,70 @@ export const providerReviewRouter = createTRPCRouter({ * 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, { + 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, - }), - 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, - }); - }), + }); + 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, { + 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, - }), - 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, - }); - }), + }); + return { supported: true as const, reason: '', ...result }; + }, + reconcileAmbiguous: false, + }); + }), }); diff --git a/packages/app-shared/src/provider-review/contracts.ts b/packages/app-shared/src/provider-review/contracts.ts index bad348a661..5f9e523635 100644 --- a/packages/app-shared/src/provider-review/contracts.ts +++ b/packages/app-shared/src/provider-review/contracts.ts @@ -8,11 +8,11 @@ * difference never leaks past its mapper. */ -export type ProviderPrPlatform = "github" | "gitlab" | "bitbucket"; +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"; + platform: 'github'; owner: string; repo: string; number: number; @@ -25,7 +25,7 @@ export type GitHubPrRef = { * MUST never be used as an API base. */ export type GitLabMrRef = { - platform: "gitlab"; + platform: 'gitlab'; projectPath: string; mrIid: number; instanceHint?: string; @@ -33,7 +33,7 @@ export type GitLabMrRef = { /** A Bitbucket Cloud pull request: `workspace/repoSlug` plus the numeric `prId`. */ export type BitbucketPrRef = { - platform: "bitbucket"; + platform: 'bitbucket'; workspace: string; repoSlug: string; prId: number; @@ -57,22 +57,17 @@ export type ProviderPrRef = GitHubPrRef | GitLabMrRef | BitbucketPrRef; */ export function providerPrRefKey(ref: ProviderPrRef): string { switch (ref.platform) { - case "github": - return JSON.stringify(["github", ref.owner, ref.repo, ref.number]); - case "gitlab": + case 'github': + return JSON.stringify(['github', ref.owner, ref.repo, ref.number]); + case 'gitlab': return JSON.stringify([ - "gitlab", + 'gitlab', gitlabInstanceOrigin(ref.instanceHint), ref.projectPath, ref.mrIid, ]); - case "bitbucket": - return JSON.stringify([ - "bitbucket", - ref.workspace, - ref.repoSlug, - ref.prId, - ]); + case 'bitbucket': + return JSON.stringify(['bitbucket', ref.workspace, ref.repoSlug, ref.prId]); } } @@ -84,11 +79,11 @@ export function providerPrRefKey(ref: ProviderPrRef): string { * a hint can never collide with one pinned to the SaaS host. */ export function gitlabInstanceOrigin(instanceHint?: string): string { - if (!instanceHint) return ""; + 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] ?? ""; + return (rest.split('/')[0] ?? '').split('?')[0] ?? ''; } /** An author or reviewer identity. `login` is the provider username. */ @@ -98,10 +93,10 @@ export type ProviderPrAuthor = { }; /** The lifecycle state every provider maps onto. */ -export type ProviderPrState = "open" | "closed" | "merged"; +export type ProviderPrState = 'open' | 'closed' | 'merged'; /** Which side of a diff a comment or thread anchors to. */ -export type ProviderPrDiffSide = "LEFT" | "RIGHT"; +export type ProviderPrDiffSide = 'LEFT' | 'RIGHT'; /** * The diff position one inline review comment anchors to. `line` is the @@ -222,13 +217,13 @@ export type ProviderPrChecksResult = { */ export type ProviderPrMergeBlockedReason = { code: - | "conflicts" - | "required_approvals" - | "failing_pipeline" - | "pending_pipeline" - | "draft" - | "permission" - | "other"; + | 'conflicts' + | 'required_approvals' + | 'failing_pipeline' + | 'pending_pipeline' + | 'draft' + | 'permission' + | 'other'; message: string; };