From 2c9069aa092739516aaa63bac7b61c49a45cda87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 7 Sep 2026 06:04:08 +0200 Subject: [PATCH 1/5] chore: one mobile presentation: provider-aware PR review read surface (inbox, detail, files, diffs, discussions, checks) (kwf bring-mobile-gitlab-and-bitb-3792/s5) --- .../[platform]/[...identity]/_layout.tsx | 112 +++++ .../[...identity]/file-navigator.tsx | 5 + .../[platform]/[...identity]/index.tsx | 40 ++ .../diff/pr-diff-file-list-header.tsx | 15 +- .../pr-review/diff/pr-diff-file-list.test.tsx | 82 ++++ .../pr-review/diff/pr-diff-file-list.tsx | 54 ++- .../pr-review/diff/pr-diff-hunk-rows.tsx | 5 +- .../pr-review/diff/pr-diff-state-copy.ts | 41 ++ .../pr-review/diff/pr-diff-write-gate.ts | 46 ++ .../discussion-thread.provider-gate.test.tsx | 183 ++++++++ .../discussion/discussion-thread.tsx | 19 +- ...pr-review-discussion-list.mounted.test.tsx | 162 +++++++ .../discussion/pr-review-discussion-list.tsx | 11 +- .../pr-review-checks-section.mounted.test.tsx | 91 +++- .../pr-review/pr-review-checks-section.tsx | 48 ++- .../pr-review-discussion-tab.test.tsx | 50 ++- .../pr-review/pr-review-discussion-tab.tsx | 27 +- .../pr-review-file-navigator-screen.test.tsx | 132 ++++++ .../pr-review-file-navigator-screen.tsx | 77 +++- .../pr-review/pr-review-files-tab.tsx | 6 + .../pr-review/pr-review-inbox-list.tsx | 86 ++-- .../pr-review/pr-review-overview-parts.tsx | 18 +- .../pr-review/pr-review-overview.tsx | 115 +++-- .../pr-review/pr-review-screen.test.tsx | 84 +++- .../components/pr-review/pr-review-screen.tsx | 125 ++++-- .../diff/pr-review-file-list-state.ts | 33 +- .../diff/use-pr-diff-context-loader.test.ts | 60 +++ .../diff/use-pr-diff-context-loader.ts | 62 ++- .../use-pr-review-discussion-threads.ts | 53 +-- .../lib/pr-review/provider-pr-queries.test.ts | 338 +++++++++++++++ .../src/lib/pr-review/provider-pr-queries.ts | 403 ++++++++++++++++++ .../src/lib/pr-review/provider-pr-ref.test.ts | 241 +++++++++++ .../src/lib/pr-review/provider-pr-ref.ts | 296 +++++++++++++ .../lib/pr-review/use-provider-inbox.test.ts | 307 +++++++++++++ .../src/lib/pr-review/use-provider-inbox.ts | 336 +++++++++++++++ .../lib/provider-review/gitlab-write.test.ts | 104 ++++- .../src/lib/provider-review/gitlab-write.ts | 89 +++- .../src/routers/github-pr-review-router.ts | 2 +- apps/web/src/routers/root-router.test.ts | 6 + apps/web/src/routers/root-router.ts | 2 + .../app-shared/src/analytics/event-map.ts | 7 + .../src/pr-review/intent-fingerprint.ts | 18 +- .../src/provider-review/contracts.ts | 2 +- .../internal-service-token-audiences.test.ts | 4 + .../src/internal-service-token-audiences.ts | 2 + services/git-token-service/src/index.test.ts | 183 ++++++++ services/git-token-service/src/index.ts | 101 ++++- 47 files changed, 4002 insertions(+), 281 deletions(-) create mode 100644 apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/_layout.tsx create mode 100644 apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/file-navigator.tsx create mode 100644 apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/index.tsx create mode 100644 apps/mobile/src/components/pr-review/diff/pr-diff-state-copy.ts create mode 100644 apps/mobile/src/components/pr-review/diff/pr-diff-write-gate.ts create mode 100644 apps/mobile/src/components/pr-review/discussion/discussion-thread.provider-gate.test.tsx create mode 100644 apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.mounted.test.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.test.tsx create mode 100644 apps/mobile/src/lib/pr-review/diff/use-pr-diff-context-loader.test.ts create mode 100644 apps/mobile/src/lib/pr-review/provider-pr-queries.test.ts create mode 100644 apps/mobile/src/lib/pr-review/provider-pr-queries.ts create mode 100644 apps/mobile/src/lib/pr-review/provider-pr-ref.test.ts create mode 100644 apps/mobile/src/lib/pr-review/provider-pr-ref.ts create mode 100644 apps/mobile/src/lib/pr-review/use-provider-inbox.test.ts create mode 100644 apps/mobile/src/lib/pr-review/use-provider-inbox.ts diff --git a/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/_layout.tsx b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/_layout.tsx new file mode 100644 index 0000000000..167e64b6a0 --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/_layout.tsx @@ -0,0 +1,112 @@ +import { type Href, Redirect, Stack, useLocalSearchParams } from 'expo-router'; +import { useMemo } from 'react'; + +import { appUnlockScreenLayout } from '@/components/app-unlock-screen'; +import { InvalidRouteState } from '@/components/invalid-route-state'; +import { useFormSheetDetents } from '@/lib/form-sheet'; +import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; +import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh'; +import { useOrganization } from '@/lib/organization-context'; +import { + pendingReviewDraftKey, + PendingReviewProvider, +} from '@/lib/pr-review/pending-review-provider'; +import { + parseProviderPrRoute, + providerPrRefKey, + providerPrRoutePath, + ProviderPrScopeProvider, + providerPrTriple, +} from '@/lib/pr-review/provider-pr-ref'; +import { parseParam } from '@/lib/route-params'; + +type Params = { + platform: string; + identity: string[]; + instance?: string; +}; + +/** + * Param guard + scope hoist for the provider PR-review surface. + * + * The route is `[platform]/[...identity]`, where the LAST identity segment is + * the number (a GitLab MR iid, a Bitbucket PR id) and everything before it is + * the project path — a GitLab FULL nested path (`group/sub/repo`) or a + * Bitbucket `workspace/repo`. `parseProviderPrRoute` validates every segment, + * so a hand-built deep link with a missing, repeated or non-numeric segment + * never reaches a query. + * + * GitHub keeps its original `[owner]/[repo]/[number]` route untouched — this + * layout only redirects a hand-built `/pr-review/github/...` link there so + * that surface, its connect gate and its write sheets stay exactly as they + * were. + * + * The scope (ref + organization) is published in context rather than threaded + * through props: the diff list, the file navigator and the discussion tree + * take the GitHub-shaped `owner`/`repo`/`number` triple, and reading the real + * ref from context moves their queries to the right provider without a + * per-provider copy of that tree. + */ +export default function ProviderPrReviewLayout() { + const params = useLocalSearchParams(); + const platform = parseParam(params.platform) ?? ''; + // A catch-all param is a fresh array on every render; the joined form is a + // stable dependency, and a `/` inside a segment is percent-encoded by + // `providerPrRoutePath`, so splitting it back is lossless. + const identity = Array.isArray(params.identity) + ? params.identity.join('/') + : (parseParam(params.identity) ?? ''); + const instance = parseParam(params.instance) ?? ''; + const { organizationId } = useOrganization(); + const { fullSheetDetent } = useFormSheetDetents(); + const { userId } = useCurrentUserId(); + useRouteForegroundRefresh([[['providerReview']]]); + + const ref = useMemo( + () => + parseProviderPrRoute({ + platform, + identity: identity.split('/'), + instance: instance || undefined, + }), + [platform, identity, instance] + ); + const scope = useMemo(() => (ref ? { ref, organizationId } : null), [ref, organizationId]); + + if (!ref || !scope) { + return ; + } + + if (ref.platform === 'github') { + return ; + } + + // One draft queue per PR/MR: the GitHub-shaped key the store already uses, + // suffixed with the s1 collision-free ref identity so a GitLab MR and a + // GitHub PR that share `owner/repo#number` — and one project reached on two + // GitLab instances — never share a queue. + const triple = providerPrTriple(ref); + const draftEntityKey = `${pendingReviewDraftKey(triple.owner, triple.repo, triple.number)}@${providerPrRefKey(ref)}`; + + const sheetOptions = { + presentation: 'formSheet' as const, + sheetAllowedDetents: [0.5, fullSheetDetent] as [number, number], + sheetInitialDetentIndex: 'last' as const, + sheetGrabberVisible: true, + headerShown: false, + }; + + return ( + + + + + + + + ); +} diff --git a/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/file-navigator.tsx b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/file-navigator.tsx new file mode 100644 index 0000000000..f5e1922b64 --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/file-navigator.tsx @@ -0,0 +1,5 @@ +import { PrReviewFileNavigatorScreen } from '@/components/pr-review/pr-review-file-navigator-screen'; + +export default function ProviderPrReviewFileNavigatorRoute() { + return ; +} diff --git a/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/index.tsx b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/index.tsx new file mode 100644 index 0000000000..33d1ce0458 --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/index.tsx @@ -0,0 +1,40 @@ +import { type Href, Stack, useLocalSearchParams } from 'expo-router'; + +import { InvalidRouteState } from '@/components/invalid-route-state'; +import { PrReviewScreen } from '@/components/pr-review/pr-review-screen'; +import { parseProviderPrRoute, providerPrTriple } from '@/lib/pr-review/provider-pr-ref'; +import { parseParam } from '@/lib/route-params'; + +type Params = { + platform: string; + identity: string[]; + instance?: string; +}; + +/** + * The provider PR/MR detail screen. The layout above already validated the + * route and published the scope, so the screen renders through the same tree + * GitHub uses; the triple it takes is the GitHub-shaped identity its stores + * are keyed on, while its queries follow the ref from the scope. + */ +export default function ProviderPrReviewIndexRoute() { + const params = useLocalSearchParams(); + const ref = parseProviderPrRoute({ + platform: parseParam(params.platform) ?? '', + identity: params.identity, + instance: params.instance, + }); + + if (!ref) { + return ; + } + + const { owner, repo, number } = providerPrTriple(ref); + + return ( + <> + + + + ); +} diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx index 6695116faa..73eb8d1ea1 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx @@ -21,6 +21,7 @@ import { formatNumber } from '@/lib/format'; import { useIsTablet } from '@/lib/hooks/use-is-tablet'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type DiffViewMode } from '@/lib/pr-review/diff/pr-diff-list-items'; +import { providerPrChildRoutePath, useProviderPrScope } from '@/lib/pr-review/provider-pr-ref'; import { cn } from '@/lib/utils'; type PrDiffFileListHeaderProps = { @@ -34,8 +35,6 @@ type PrDiffFileListHeaderProps = { readonly onViewModeChange: (mode: DiffViewMode) => void; }; -const FILE_NAVIGATOR_PATH = '/(app)/pr-review/[owner]/[repo]/[number]/file-navigator' as const; - export function PrDiffFileListHeader({ owner, repo, @@ -51,10 +50,14 @@ export function PrDiffFileListHeader({ const colors = useThemeColors(); const { t } = useTranslation(); - const navigatorHref = useMemo( - () => ({ pathname: FILE_NAVIGATOR_PATH, params: { owner, repo, number } }), - [owner, repo, number] - ); + // The sheet is a sibling of the screen it was opened from, so its href is + // built from the live scope: a GitHub ref keeps the original + // `[owner]/[repo]/[number]/file-navigator` path, a GitLab or Bitbucket ref + // opens the sheet inside the provider layout — the only place its scope is + // published, and therefore the only place the sheet can query the right + // provider. + const { ref } = useProviderPrScope({ owner, repo, number }); + const navigatorHref = useMemo(() => providerPrChildRoutePath(ref, 'file-navigator'), [ref]); const handleOpenNavigator = useCallback(() => { router.push(navigatorHref); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.test.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.test.tsx index a8b16de647..6e9a5f8719 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.test.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.test.tsx @@ -4,6 +4,12 @@ import { type RefreshControlProps } from 'react-native'; import TestRenderer, { act } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + PR_DIFF_FLOATING_ACTIONS_FALLBACK_HEIGHT, + PR_DIFF_LIST_FOOTER_GAP, +} from '@/lib/pr-review/diff/pr-diff-list-bottom-padding'; +import { ProviderPrScopeProvider } from '@/lib/pr-review/provider-pr-ref'; + import { PrReviewFileList } from './pr-diff-file-list'; const insetsState = vi.hoisted(() => ({ top: 0, bottom: 0, left: 0, right: 0 })); @@ -129,6 +135,29 @@ function mountList(changedFiles = BASE_PROPS.changedFiles): TestRenderer.ReactTe return renderer; } +function mountListInScope( + ref: Parameters[0]['value']['ref'] +): TestRenderer.ReactTestRenderer { + const holder: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + holder.current = TestRenderer.create( + + + + ); + }); + const renderer = holder.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +function listBottomPadding(renderer: TestRenderer.ReactTestRenderer): number { + const list = renderer.root.find(node => String(node.type) === 'FlashList'); + return (list.props.contentContainerStyle as { paddingBottom: number }).paddingBottom; +} + function bottomPaddedViews( renderer: TestRenderer.ReactTestRenderer ): TestRenderer.ReactTestInstance[] { @@ -211,3 +240,56 @@ describe('PrReviewFileList full-body states', () => { expect(renderer.root.findAll(node => String(node.type) === 'QueryError')).toHaveLength(0); }); }); + +// The comment composer and the review-submit sheet are siblings of the GitHub +// route only, so the write bar must not be offered on a provider MR/PR — it +// would push a GitHub route carrying a GitLab or Bitbucket identity. +describe('PrReviewFileList write affordances per provider', () => { + beforeEach(() => { + insetsState.bottom = 0; + resetState(); + listQueryState.files = [{ path: 'src/file.ts' }]; + }); + + it('keeps the write bar on a GitHub pull request', () => { + const renderer = mountList(); + expect( + renderer.root.findAll(node => String(node.type) === 'PrDiffFloatingActions') + ).toHaveLength(1); + }); + + it('hides the write bar on a GitLab merge request', () => { + const renderer = mountListInScope({ + platform: 'gitlab', + projectPath: 'group/sub/repo', + mrIid: 12, + }); + expect( + renderer.root.findAll(node => String(node.type) === 'PrDiffFloatingActions') + ).toHaveLength(0); + }); + + it('hides the write bar on a Bitbucket pull request', () => { + const renderer = mountListInScope({ + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'api', + prId: 42, + }); + expect( + renderer.root.findAll(node => String(node.type) === 'PrDiffFloatingActions') + ).toHaveLength(0); + }); + + it('reserves no bar-sized gap under a provider diff list', () => { + const githubPadding = listBottomPadding(mountList()); + const gitlabPadding = listBottomPadding( + mountListInScope({ platform: 'gitlab', projectPath: 'group/repo', mrIid: 12 }) + ); + // GitHub reserves the bar's fallback height plus the footer gap; a + // provider list keeps only the gap, so the diff does not end in a hole + // where no bar is drawn. + expect(githubPadding).toBe(PR_DIFF_FLOATING_ACTIONS_FALLBACK_HEIGHT + PR_DIFF_LIST_FOOTER_GAP); + expect(gitlabPadding).toBe(PR_DIFF_LIST_FOOTER_GAP); + }); +}); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx index b6108f3fa9..af8e7ee515 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx @@ -26,7 +26,7 @@ import { FlashList, type FlashListRef } from '@shopify/flash-list'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { RefreshControl, View, type ViewStyle } from 'react-native'; +import { RefreshControl, View } from 'react-native'; import { QueryError } from '@/components/query-error'; import { @@ -40,11 +40,12 @@ import { } from '@/components/pr-review/diff/pr-diff-file-list-header'; import { PrDiffFileListLoading } from '@/components/pr-review/diff/pr-diff-file-list-loading'; import { PrDiffFloatingActions } from '@/components/pr-review/diff/pr-diff-floating-actions'; +import { NO_LINE_TAP, usePrDiffWriteGate } from '@/components/pr-review/diff/pr-diff-write-gate'; +import { usePrDiffStateCopy } from '@/components/pr-review/diff/pr-diff-state-copy'; import { useDiffRenderItem } from '@/components/pr-review/diff/pr-diff-file-list-render'; import { useDiffSelection } from '@/components/pr-review/diff/use-diff-selection'; import { EmptyFilesView, TabStateMessage } from '@/components/pr-review/diff/pr-diff-rows'; import { buildFileItems, buildPaginationItem } from '@/lib/pr-review/diff/pr-diff-list-builder'; -import { prDiffListBottomPadding } from '@/lib/pr-review/diff/pr-diff-list-bottom-padding'; import { itemTypeFor, type ListItem } from '@/lib/pr-review/diff/pr-diff-list-items'; import { stickyFileHeaderIndices } from '@/lib/pr-review/diff/sticky-file-headers'; import { usePrDiffContextLoader } from '@/lib/pr-review/diff/use-pr-diff-context-loader'; @@ -134,10 +135,12 @@ export function PrReviewFileList({ }); }, []); - const contentContainerStyle = useMemo( - () => ({ paddingBottom: prDiffListBottomPadding(barHeight) }), - [barHeight] - ); + // Which write affordances this provider can offer, and the space its bar + // needs reserved (none, where no bar is drawn). + const write = usePrDiffWriteGate({ owner, repo, number }, barHeight); + + // Which provider's words the terminal and empty states use. + const copy = usePrDiffStateCopy({ owner, repo, number }); const viewedCount = useMemo(() => { let count = 0; @@ -255,25 +258,17 @@ export function PrReviewFileList({ onFetchAll, handleLoadContext, setExpanded, - onLineTap: handleLineTap, - selection: selectionView, + onLineTap: write.canReviewInline ? handleLineTap : NO_LINE_TAP, + selection: write.canReviewInline ? selectionView : null, }); if (files.length === 0) { if (firstPageErrorState?.kind === 'not-found') { - return ( - - ); + return ; } if (firstPageErrorState?.kind === 'permission') { return ( - + ); } if (firstPageErrorState?.kind === 'reconnect') { @@ -300,6 +295,7 @@ export function PrReviewFileList({ return ( 0 ? ( @@ -356,19 +352,21 @@ export function PrReviewFileList({ } }} onEndReachedThreshold={0.5} - contentContainerStyle={contentContainerStyle} + contentContainerStyle={write.listContentStyle} ItemSeparatorComponent={null} /> )} - + {write.canReviewInline ? ( + + ) : null} ); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx index c592db7706..c11d423ca9 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx @@ -273,10 +273,13 @@ export function TabStateMessage({ title, message }: { title: string; message: st export function EmptyFilesView({ changedFiles, + noChangesDescription, onRequestOverview, refreshControl, }: { changedFiles: number; + /** Provider wording for the 0-changed-files case; GitHub copy when absent. */ + noChangesDescription?: string; onRequestOverview?: () => void; refreshControl?: ScrollViewProps['refreshControl']; }) { @@ -291,7 +294,7 @@ export function EmptyFilesView({ {changedFiles === 0 - ? t('prReview.noFilesChangedDescription') + ? (noChangesDescription ?? t('prReview.noFilesChangedDescription')) : t('prReview.hunkRows.filesStillLoading')} {onRequestOverview ? ( diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-state-copy.ts b/apps/mobile/src/components/pr-review/diff/pr-diff-state-copy.ts new file mode 100644 index 0000000000..740d3f2625 --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-state-copy.ts @@ -0,0 +1,41 @@ +// Provider wording for the file list's terminal and empty states. +// +// GitLab calls this a merge request, and its copy must not name the Kilo +// GitHub App or "this pull request"; GitHub and Bitbucket both say pull +// request, so the provider term alone switches the strings and the list keeps +// one set of states for all three providers. + +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { type ProviderPrTriple, useProviderPrScope } from '@/lib/pr-review/provider-pr-ref'; + +type PrDiffStateCopy = { + readonly unavailableTitle: string; + readonly unavailableMessage: string; + readonly accessDeniedMessage: string; + /** Undefined keeps the GitHub wording inside `EmptyFilesView`. */ + readonly noChangesDescription: string | undefined; +}; + +export function usePrDiffStateCopy(triple: ProviderPrTriple): PrDiffStateCopy { + const { t } = useTranslation(); + const isMergeRequest = useProviderPrScope(triple).ref.platform === 'gitlab'; + return useMemo( + () => ({ + unavailableTitle: isMergeRequest + ? t('prReview.terms.mergeRequestUnavailable') + : t('prReview.pullRequestUnavailable'), + unavailableMessage: isMergeRequest + ? t('prReview.terms.unavailableDescription') + : t('prReview.pullRequestUnavailableDescription'), + accessDeniedMessage: isMergeRequest + ? t('prReview.terms.accessDeniedMergeRequest') + : t('prReview.accessDeniedDescription'), + noChangesDescription: isMergeRequest + ? t('prReview.terms.noFilesChangedDescription') + : undefined, + }), + [isMergeRequest, t] + ); +} diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-write-gate.ts b/apps/mobile/src/components/pr-review/diff/pr-diff-write-gate.ts new file mode 100644 index 0000000000..3b9556c61a --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-write-gate.ts @@ -0,0 +1,46 @@ +// GitHub-only gate for the diff list's write affordances. +// +// The comment composer and the review-submit sheet are siblings of the GitHub +// route — the provider write surfaces land with the provider write slice — so +// on a GitLab merge request or a Bitbucket pull request the whole write bar is +// withheld; offering it would push a GitHub route carrying a provider +// identity. Line selection goes with it: it exists only to feed that bar, and +// a tap would otherwise leave lines highlighted that the reader can neither +// comment on nor clear, since Clear lives in the same bar. +// +// The bar's reserved space goes with it too. Space is reserved at the bar's +// final size where a bar is drawn, and not at all where none is, so a provider +// diff does not end in a bar-sized hole. + +import { useMemo } from 'react'; +import { type ViewStyle } from 'react-native'; + +import { + PR_DIFF_LIST_FOOTER_GAP, + prDiffListBottomPadding, +} from '@/lib/pr-review/diff/pr-diff-list-bottom-padding'; +import { type ProviderPrTriple, useProviderPrScope } from '@/lib/pr-review/provider-pr-ref'; + +type PrDiffWriteGate = { + /** True only where the review write routes can actually be reached. */ + readonly canReviewInline: boolean; + /** Content style for the diff list, reserving the bar's space where drawn. */ + readonly listContentStyle: ViewStyle; +}; + +/** Tap handler for the diff rows where selection has no CTA to feed. */ +export const NO_LINE_TAP = (): void => undefined; + +export function usePrDiffWriteGate( + triple: ProviderPrTriple, + barHeight: number | null +): PrDiffWriteGate { + const canReviewInline = useProviderPrScope(triple).ref.platform === 'github'; + const paddingBottom = canReviewInline + ? prDiffListBottomPadding(barHeight) + : PR_DIFF_LIST_FOOTER_GAP; + return useMemo( + () => ({ canReviewInline, listContentStyle: { paddingBottom } }), + [canReviewInline, paddingBottom] + ); +} diff --git a/apps/mobile/src/components/pr-review/discussion/discussion-thread.provider-gate.test.tsx b/apps/mobile/src/components/pr-review/discussion/discussion-thread.provider-gate.test.tsx new file mode 100644 index 0000000000..379747ffaa --- /dev/null +++ b/apps/mobile/src/components/pr-review/discussion/discussion-thread.provider-gate.test.tsx @@ -0,0 +1,183 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to test React/RN structure under vitest */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import { type ReviewThread } from '@/lib/pr-review/discussion/review-discussion-types'; +import { type ProviderPrRef, ProviderPrScopeProvider } from '@/lib/pr-review/provider-pr-ref'; + +import { DiscussionThread } from './discussion-thread'; + +// The provider read surface must not offer the GitHub-only write controls: +// the resolve toggle, the reply input and the reaction picker all call +// `githubPrReview` mutations, which cannot work for a GitLab MR or a +// Bitbucket PR identity (the same rule the diff list's write bar follows in +// `pr-diff-write-gate`). The read-only facts the providers DO report — the +// Resolved badge — stay rendered. + +const GITLAB_REF: ProviderPrRef = { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 12 }; +const BITBUCKET_REF: ProviderPrRef = { + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'api', + prId: 42, +}; + +function makeThread(overrides: Partial = {}): ReviewThread { + return { + threadId: 'D-12', + isResolved: false, + isOutdated: false, + subjectType: 'LINE', + path: 'src/index.ts', + line: 10, + startLine: null, + originalLine: null, + originalStartLine: null, + diffSide: 'RIGHT', + diffHunk: null, + comments: [ + { + commentId: 12, + nodeId: '12', + author: { login: 'alice', avatarUrl: null }, + bodyMarkdown: 'hello', + createdAt: '2024-01-01T00:00:00Z', + reactions: [], + }, + ], + ...overrides, + }; +} + +const baseProps = { + owner: 'group/sub', + repo: 'repo', + number: 12, + onToggleExpand: vi.fn<() => void>(), +}; + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + View: 'View', +})); +vi.mock('expo-haptics', () => ({ selectionAsync: vi.fn() })); +vi.mock('@/components/ui/icons', () => ({ + Check: 'Check', + CheckCheck: 'CheckCheck', + ChevronDown: 'ChevronDown', + ChevronUp: 'ChevronUp', +})); +vi.mock('@/components/pr-review/discussion/comment-row', () => ({ CommentRow: 'CommentRow' })); +vi.mock('@/components/pr-review/discussion/reply-input', () => ({ ReplyInput: 'ReplyInput' })); +vi.mock('@/components/pr-review/discussion/thread-diff-snippet', () => ({ + ThreadDiffSnippet: 'ThreadDiffSnippet', +})); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#6F6A61', good: '#22C55E' }), +})); +vi.mock('@/lib/pr-review/discussion/use-review-discussion-mutations', () => ({ + useAddReactionMutation: () => ({ mutate: vi.fn(), isPending: false }), + useRemoveReactionMutation: () => ({ mutate: vi.fn(), isPending: false }), + useReplyToCommentMutation: () => ({ mutate: vi.fn(), isPending: false }), + useResolveThreadMutation: () => ({ mutate: vi.fn(), isPending: false }), + useUnresolveThreadMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +function countByType( + root: TestRenderer.ReactTestInstance, + type: string, + match?: (props: Record) => boolean +): number { + return root.findAll( + node => + typeof node.type === 'string' && + (node.type as string) === type && + (match === undefined || match(node.props as Record)) + ).length; +} + +async function renderThread( + ref: ProviderPrRef | null, + expanded: boolean, + thread: ReviewThread +): Promise { + const card = createElement(DiscussionThread, { ...baseProps, thread, expanded }); + const tree = ref ? ( + {card} + ) : ( + card + ); + let renderer: TestRenderer.ReactTestRenderer | null = null; + await act(async () => { + await Promise.resolve(); + renderer = TestRenderer.create(tree); + }); + // Runtime safety: act() could theoretically fail without assigning. + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition + if (!renderer) { + throw new Error('Failed to create test renderer'); + } + return renderer; +} + +function pressableCount(root: TestRenderer.ReactTestInstance, label: string): number { + return countByType( + root, + 'Pressable', + props => props.accessibilityLabel === label || props.children === label + ); +} + +describe('DiscussionThread provider write gate', () => { + it.each<[string, ProviderPrRef]>([ + ['gitlab', GITLAB_REF], + ['bitbucket', BITBUCKET_REF], + ])('withholds resolve, reply and reaction writes on a %s thread', async (_platform, ref) => { + const renderer = await renderThread(ref, true, makeThread()); + try { + expect(pressableCount(renderer.root, 'Resolve thread')).toBe(0); + expect(countByType(renderer.root, 'ReplyInput')).toBe(0); + expect(countByType(renderer.root, 'CommentRow')).toBe(1); + } finally { + renderer.unmount(); + } + }); + + it('makes thread comments read-only on a provider scope', async () => { + const renderer = await renderThread(GITLAB_REF, true, makeThread()); + try { + const row = renderer.root.find( + node => typeof node.type === 'string' && (node.type as string) === 'CommentRow' + ); + expect(row.props.readOnly).toBe(true); + } finally { + renderer.unmount(); + } + }); + + it('keeps the resolve toggle, reply input and writable reactions on GitHub', async () => { + const renderer = await renderThread(null, true, makeThread()); + try { + expect(pressableCount(renderer.root, 'Resolve thread')).toBe(1); + expect(countByType(renderer.root, 'ReplyInput')).toBe(1); + const row = renderer.root.find( + node => typeof node.type === 'string' && (node.type as string) === 'CommentRow' + ); + expect(row.props.readOnly).toBe(false); + } finally { + renderer.unmount(); + } + }); + + it('keeps the read-only Resolved badge on a provider thread', async () => { + const renderer = await renderThread(GITLAB_REF, false, makeThread({ isResolved: true })); + try { + expect(pressableCount(renderer.root, 'Unresolve thread')).toBe(0); + expect(countByType(renderer.root, 'Text', props => props.children === 'Resolved')).toBe(1); + } finally { + renderer.unmount(); + } + }); +}); diff --git a/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx index 9bbd0574fa..fe0d956156 100644 --- a/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx +++ b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx @@ -55,6 +55,7 @@ import { useResolveThreadMutation, useUnresolveThreadMutation, } from '@/lib/pr-review/discussion/use-review-discussion-mutations'; +import { useProviderPrScope } from '@/lib/pr-review/provider-pr-ref'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn, parseTimestamp, timeAgo } from '@/lib/utils'; @@ -79,6 +80,13 @@ export function DiscussionThread({ onToggleExpand, viewerLogin = null, }: Readonly) { + // The resolve / reply / reaction controls below call the `githubPrReview` + // mutations, and the provider write routes are siblings of the GitHub + // route only — so on a GitLab MR or Bitbucket PR the whole write row is + // withheld, exactly like the diff list's write bar (`pr-diff-write-gate`). + // A dead button that always fails is worse than no button; the Resolved + // badge stays, because the provider read layer reports it. + const canWrite = useProviderPrScope({ owner, repo, number }).ref.platform === 'github'; const resolve = useResolveThreadMutation(); const unresolve = useUnresolveThreadMutation(); const addReaction = useAddReactionMutation(thread.threadId); @@ -131,6 +139,7 @@ export function DiscussionThread({ firstTimestamp: firstComment?.createdAt ?? null, expanded, onToggleResolve, + canResolve: canWrite, resolveDisabled: isResolving, onToggleExpand, } as const; @@ -149,6 +158,7 @@ export function DiscussionThread({ { onToggleReaction(comment, content); @@ -157,7 +167,7 @@ export function DiscussionThread({ ))} - {firstComment ? ( + {firstComment && canWrite ? ( void; readonly onToggleResolve: () => void; + /** False on a GitLab/Bitbucket scope: the resolve control is withheld. */ + readonly canResolve: boolean; readonly resolveDisabled: boolean; }; @@ -208,6 +220,7 @@ function ThreadHeader({ expanded, onToggleExpand, onToggleResolve, + canResolve, resolveDisabled, }: Readonly) { const colors = useThemeColors(); @@ -237,7 +250,9 @@ function ThreadHeader({ {anchorLabel} - + {canResolve ? ( + + ) : null} {resolved ? ( diff --git a/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.mounted.test.tsx b/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.mounted.test.tsx new file mode 100644 index 0000000000..e29a6d8fa7 --- /dev/null +++ b/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.mounted.test.tsx @@ -0,0 +1,162 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the repository's native-free mounted test tool. */ +import { createElement, Fragment, type ReactNode } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type DiscussionListItem } from '@/lib/pr-review/discussion/review-discussion-types'; +import { ProviderPrScopeProvider } from '@/lib/pr-review/provider-pr-ref'; +import { PrReviewDiscussionList } from './pr-review-discussion-list'; + +type TaggedOptions = { tag: string; input?: unknown }; + +const observed = vi.hoisted(() => ({ options: [] as TaggedOptions[] })); + +// Records every query the list mounts and answers the overview with a viewer +// login, so a test can assert BOTH which namespace was asked and what the +// answer drives. +vi.mock('@tanstack/react-query', () => ({ + useQuery: (options: TaggedOptions) => { + observed.options.push(options); + if (options.tag === 'moderation') { + return { data: { blockedLogins: [], mutedLogins: [] } }; + } + return { data: { repo: { viewerLogin: 'octocat' } } }; + }, +})); +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + moderation: { listHiddenUsers: { queryOptions: () => ({ tag: 'moderation' }) } }, + githubPrReview: { + getPullRequest: { + queryOptions: (input: unknown) => ({ tag: 'githubPrReview.getPullRequest', input }), + }, + }, + providerReview: { + getPullRequest: { + queryOptions: (input: unknown) => ({ tag: 'providerReview.getPullRequest', input }), + }, + }, + }), +})); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('react-native', () => ({ View: 'View' })); +vi.mock('@shopify/flash-list', () => ({ + FlashList: ({ + data, + renderItem, + }: { + data: readonly unknown[]; + renderItem: (args: { item: unknown; index: number }) => ReactNode; + }) => + createElement( + Fragment, + null, + data.map((item, index) => + createElement(Fragment, { key: index }, renderItem({ item, index })) + ) + ), +})); +vi.mock('@/components/pr-review/discussion/comment-row', () => ({ CommentRow: 'CommentRow' })); +vi.mock('@/components/pr-review/discussion/discussion-thread', () => ({ + DiscussionThread: 'DiscussionThread', +})); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/lib/screen-insets', () => ({ useDetailScreenBottomPadding: () => 0 })); + +function noop(): void { + // The viewer query is what this file asserts; the list callbacks are inert. +} + +const listItems: readonly DiscussionListItem[] = [ + { + kind: 'comment', + comment: { + commentId: 1, + nodeId: 'c1', + author: { login: 'octocat', avatarUrl: null }, + bodyMarkdown: 'hello', + createdAt: '2026-01-01T00:00:00Z', + reactions: [], + }, + }, +]; + +function mountList(scope?: { + ref: { platform: 'gitlab'; projectPath: string; mrIid: number }; + organizationId: string | null; +}) { + const list = ( + + ); + const created: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + created.current = TestRenderer.create( + scope ? {list} : list + ); + }); + const renderer = created.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +describe('PrReviewDiscussionList viewer query', () => { + beforeEach(() => { + observed.options = []; + }); + + it('reads the viewer login from the GitHub procedure on a GitHub scope', () => { + const renderer = mountList(); + + expect(observed.options.map(options => options.tag)).toEqual([ + 'moderation', + 'githubPrReview.getPullRequest', + ]); + expect(observed.options[1]?.input).toEqual({ owner: 'group/sub', repo: 'repo', number: 12 }); + expect( + renderer.root.findAll(node => String(node.type) === 'CommentRow')[0]?.props.viewerLogin + ).toBe('octocat'); + + act(() => { + renderer.unmount(); + }); + }); + + it('never fires the GitHub procedure under a GitLab scope', () => { + const renderer = mountList({ + ref: { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 12 }, + organizationId: null, + }); + + expect(observed.options.map(options => options.tag)).toEqual([ + 'moderation', + 'providerReview.getPullRequest', + ]); + expect(observed.options[1]?.input).toMatchObject({ + platform: 'gitlab', + projectPath: 'group/sub/repo', + mrIid: 12, + }); + + act(() => { + renderer.unmount(); + }); + }); +}); diff --git a/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.tsx b/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.tsx index 88fdca7364..6bbac388cf 100644 --- a/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.tsx +++ b/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.tsx @@ -17,6 +17,7 @@ import { type ReviewThread, } from '@/lib/pr-review/discussion/review-discussion-types'; import { expandedForThread } from '@/lib/pr-review/discussion/thread-expansion'; +import { useProviderPrQueries } from '@/lib/pr-review/provider-pr-queries'; import { useDetailScreenBottomPadding } from '@/lib/screen-insets'; import { useTRPC } from '@/lib/trpc'; @@ -61,8 +62,14 @@ export function PrReviewDiscussionList({ const trpc = useTRPC(); // Account-local hidden users (blocked + muted GitHub logins) filter rows. const hiddenUsers = useQuery(trpc.moderation.listHiddenUsers.queryOptions()); - // Viewer login for self-target gating on the comment overflow menu. - const pr = useQuery(trpc.githubPrReview.getPullRequest.queryOptions({ owner, repo, number })); + // Viewer login for self-target gating on the comment overflow menu. The + // overview goes through the provider seam, not `githubPrReview` directly: + // this list also renders under a GitLab MR / Bitbucket PR scope, where the + // GitHub-shaped triple is a synthesized stand-in and a GitHub call with it + // would fail on every render. On GitHub the key is unchanged, so this + // still dedupes with the screen's own overview query. + const queries = useProviderPrQueries({ owner, repo, number }); + const pr = useQuery(queries.overviewOptions()); const viewerLogin = pr.data?.repo.viewerLogin ?? null; const hiddenLogins = useMemo(() => { diff --git a/apps/mobile/src/components/pr-review/pr-review-checks-section.mounted.test.tsx b/apps/mobile/src/components/pr-review/pr-review-checks-section.mounted.test.tsx index a08f8b69f9..6fec4d266c 100644 --- a/apps/mobile/src/components/pr-review/pr-review-checks-section.mounted.test.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-checks-section.mounted.test.tsx @@ -1,9 +1,11 @@ /* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the repository's native-free mounted test tool. */ import { createElement } from 'react'; import TestRenderer, { act } from 'react-test-renderer'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SpinningIcon } from '@/components/ui/spinning-icon'; +import { ProviderPrScopeProvider } from '@/lib/pr-review/provider-pr-ref'; +import { openExternalUrl } from '@/lib/external-link'; import { PrReviewChecksSection } from './pr-review-checks-section'; const query = vi.hoisted(() => ({ @@ -83,7 +85,10 @@ vi.mock('@/lib/pr-review/classify-pr-review-query-state', () => ({ classifyPrReviewQueryState: () => ({ kind: 'retryable' }), })); vi.mock('@/lib/trpc', () => ({ - useTRPC: () => ({ githubPrReview: { listChecks: { queryOptions: () => ({}) } } }), + useTRPC: () => ({ + githubPrReview: { listChecks: { queryOptions: () => ({}) } }, + providerReview: { listChecks: { queryOptions: () => ({}) } }, + }), })); describe('PrReviewChecksSection check status icons', () => { @@ -117,3 +122,85 @@ describe('PrReviewChecksSection check status icons', () => { }); }); }); + +describe('PrReviewChecksSection view-on-provider link', () => { + const gitlabScope = { + ref: { + platform: 'gitlab' as const, + projectPath: 'group/sub/repo', + mrIid: 12, + instanceHint: 'https://gitlab.example.com', + }, + organizationId: null, + }; + + function mountSection(scope?: { ref: typeof gitlabScope.ref; organizationId: string | null }) { + const previousData = query.data; + query.data = { + checkRuns: [], + rollup: { total: 0, success: 0, failure: 0, pending: 0, skipped: 0 }, + }; + const section = ( + + ); + const renderer: { current: TestRenderer.ReactTestRenderer | undefined } = { + current: undefined, + }; + act(() => { + renderer.current = TestRenderer.create( + scope ? {section} : section + ); + }); + const created = renderer.current; + if (!created) { + throw new Error('renderer was not created'); + } + return { + renderer: created, + restore: () => { + query.data = previousData; + }, + }; + } + + beforeEach(() => { + vi.mocked(openExternalUrl).mockClear(); + }); + + function pressViewButton(renderer: TestRenderer.ReactTestRenderer) { + const button = renderer.root.findAllByType('Button' as never)[0]; + if (!button) { + throw new Error('the view-on-provider button did not render'); + } + act(() => { + (button.props.onPress as () => void)(); + }); + } + + it('labels the opened link "pull request" on the GitHub arm', () => { + const { renderer, restore } = mountSection(); + pressViewButton(renderer); + + expect(openExternalUrl).toHaveBeenCalledWith('https://github.com/group/sub/repo/pull/12', { + label: 'prReview.terms.pullRequest', + }); + act(() => { + renderer.unmount(); + }); + restore(); + }); + + it('labels the same link "merge request" on a GitLab scope', () => { + const { renderer, restore } = mountSection(gitlabScope); + pressViewButton(renderer); + + expect(openExternalUrl).toHaveBeenCalledWith( + 'https://gitlab.example.com/group/sub/repo/-/merge_requests/12', + { label: 'prReview.terms.mergeRequest' } + ); + act(() => { + renderer.unmount(); + }); + restore(); + }); +}); diff --git a/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx b/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx index 705e51c57b..5be63a710f 100644 --- a/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx @@ -9,7 +9,6 @@ import { MinusCircle, XCircle, } from '@/components/ui/icons'; -import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, View } from 'react-native'; @@ -18,10 +17,12 @@ import { Button } from '@/components/ui/button'; import { SpinningIcon } from '@/components/ui/spinning-icon'; import { Text } from '@/components/ui/text'; import { i18n } from '@/i18n'; +import { reviewerPlatformLabel } from '@/lib/code-reviewer-config'; import { formatList, formatNumber } from '@/lib/format'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { classifyPrReviewQueryState } from '@/lib/pr-review/classify-pr-review-query-state'; -import { useTRPC } from '@/lib/trpc'; +import { useProviderPrQueries } from '@/lib/pr-review/provider-pr-queries'; +import { providerPrTermKey, providerPrWebUrl } from '@/lib/pr-review/provider-pr-ref'; import { cn } from '@/lib/utils'; import { openExternalUrl } from '@/lib/external-link'; @@ -179,17 +180,13 @@ export function PrReviewChecksSection({ number, headSha, }: PrReviewChecksSectionProps) { - const trpc = useTRPC(); + const queries = useProviderPrQueries({ owner, repo, number }); const colors = useThemeColors(); const { t } = useTranslation(); - const prUrl = useMemo( - () => `https://github.com/${owner}/${repo}/pull/${number}`, - [owner, repo, number] - ); + // Null on a GitLab ref with no instance hint: no host, so no link out. + const prUrl = providerPrWebUrl(queries.ref); - const checks = useQuery( - trpc.githubPrReview.listChecks.queryOptions({ owner, repo, ref: headSha }) - ); + const checks = useQuery(queries.checksOptions(headSha)); // Loading (first time, no cached data): show three skeleton rows in a // card so the section matches the final dimensions once the data lands. @@ -273,6 +270,11 @@ export function PrReviewChecksSection({ ); } + const viewOnProviderLabel = + queries.platform === 'github' + ? t('prReview.checks.viewOnGitHub') + : t('prReview.terms.viewOnProvider', { provider: reviewerPlatformLabel(queries.platform) }); + const data = checks.data; const runList = data?.checkRuns ?? []; const rollup = data?.rollup ?? { total: 0, success: 0, failure: 0, pending: 0, skipped: 0 }; @@ -286,18 +288,20 @@ export function PrReviewChecksSection({ {rollupLine} - + {prUrl ? ( + + ) : null} ); diff --git a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx index c29a18c02d..a2922dc3f9 100644 --- a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx @@ -3,6 +3,8 @@ import { createElement } from 'react'; import TestRenderer, { act } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type ProviderPrRef, ProviderPrScopeProvider } from '@/lib/pr-review/provider-pr-ref'; + import { PrReviewDiscussionTab } from './pr-review-discussion-tab'; const insetsState = vi.hoisted(() => ({ top: 0, bottom: 0, left: 0, right: 0 })); @@ -56,10 +58,19 @@ const BASE_PROPS = { onRequestFiles: vi.fn(() => undefined), }; -function mountTab(): TestRenderer.ReactTestRenderer { +/** Mounts the tab, optionally under a provider scope (no scope = GitHub). */ +function mountTab(scopeRef?: ProviderPrRef): TestRenderer.ReactTestRenderer { + const tab = createElement(PrReviewDiscussionTab, BASE_PROPS); + const tree = scopeRef ? ( + + {tab} + + ) : ( + tab + ); const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; act(() => { - ref.current = TestRenderer.create(createElement(PrReviewDiscussionTab, BASE_PROPS)); + ref.current = TestRenderer.create(tree); }); const renderer = ref.current; if (!renderer) { @@ -168,6 +179,41 @@ describe('PrReviewDiscussionTab full-body states', () => { ).toHaveLength(0); }); + // Wording, not layout: the same three states below must never call a GitLab + // merge request a "pull request". Every other state's copy is already + // provider-neutral, so it stays on one key. + const GITLAB_REF: ProviderPrRef = { + platform: 'gitlab', + projectPath: 'group/sub/repo', + mrIid: 12, + }; + + function errorMessage(scopeRef?: ProviderPrRef): unknown { + return mountTab(scopeRef).root.find(node => String(node.type) === 'QueryError').props.message; + } + + it.each(['permission', 'not-found'])('names a merge request in the %s copy', kind => { + discussionState.firstPageErrorState = { kind }; + expect(errorMessage(GITLAB_REF)).toContain('merge request'); + expect(errorMessage(GITLAB_REF)).not.toContain('pull request'); + }); + + it.each(['permission', 'not-found'])( + 'keeps the pull request copy on the %s state without a provider scope', + kind => { + discussionState.firstPageErrorState = { kind }; + expect(errorMessage()).not.toContain('merge request'); + } + ); + + it('names a merge request in the empty state description', () => { + const description = (scopeRef?: ProviderPrRef) => + mountTab(scopeRef).root.find(node => String(node.type) === 'EmptyState').props + .description as string; + expect(description(GITLAB_REF)).toContain('merge request'); + expect(description()).toContain('pull request'); + }); + it('renders the happy list without a chrome wrapper', () => { discussionState.conversation = [{ nodeId: 'c1', createdAt: null }]; const renderer = mountTab(); diff --git a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx index baca0fc176..5b5cd6735c 100644 --- a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx @@ -70,6 +70,7 @@ import { toggleThreadExpanded, } from '@/lib/pr-review/discussion/thread-expansion'; import { usePrReviewDiscussionThreads } from '@/lib/pr-review/discussion/use-pr-review-discussion-threads'; +import { useProviderPrScope } from '@/lib/pr-review/provider-pr-ref'; import { selectDiscussionTabView } from '@/components/pr-review/pr-review-discussion-tab-view'; import { useDetailScreenBottomPadding } from '@/lib/screen-insets'; @@ -100,6 +101,11 @@ export function PrReviewDiscussionTab({ }); const { t } = useTranslation(); + // GitLab calls this a merge request; GitHub and Bitbucket both say pull + // request, so the three provider-named strings below switch on that term + // alone rather than forking the tab per provider. + const { ref } = useProviderPrScope({ owner, repo, number }); + const isMergeRequest = ref.platform === 'gitlab'; const [expansion, setExpansion] = useState>({}); const [suppressContentPosition, setSuppressContentPosition] = useState(false); @@ -219,7 +225,14 @@ export function PrReviewDiscussionTab({ if (view.kind === 'permission') { return ( - + ); } if (view.kind === 'not-found') { @@ -227,7 +240,11 @@ export function PrReviewDiscussionTab({ ); } @@ -278,7 +295,11 @@ export function PrReviewDiscussionTab({ ({ + data: null as { headSha: string; counts: { changedFiles: number } } | null, + isLoading: false, + isError: false, + isFetching: false, + error: null as unknown, + refetch: vi.fn(), +})); + +const scopeState: { ref: ProviderPrRef; isReady: boolean } = vi.hoisted(() => ({ + ref: { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 12 }, + isReady: true, +})); + +vi.mock('react-native', () => ({ View: 'View', ActivityIndicator: 'ActivityIndicator' })); +vi.mock('@tanstack/react-query', () => ({ useQuery: () => queryState })); +vi.mock('expo-router', () => ({ + useLocalSearchParams: () => ({}), + useRouter: () => ({ back: vi.fn() }), +})); +vi.mock('@/lib/pr-review/provider-pr-queries', () => ({ + useProviderPrQueries: () => ({ ...scopeState, overviewOptions: () => ({}) }), +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#888' }), +})); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/pr-review/pr-review-reconnect-notice', () => ({ + PrReviewReconnectNotice: 'PrReviewReconnectNotice', +})); +vi.mock('@/components/pr-review/diff/pr-diff-file-navigator', () => ({ + PrDiffFileNavigator: 'PrDiffFileNavigator', +})); + +function mountScreen(): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create(createElement(PrReviewFileNavigatorScreen)); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +function find(renderer: TestRenderer.ReactTestRenderer, type: string) { + return renderer.root.find(node => String(node.type) === type); +} + +function trpcError(code: string): unknown { + return Object.assign(new Error(code), { data: { code }, shape: { data: { code } } }); +} + +describe('PrReviewFileNavigatorScreen states', () => { + beforeEach(() => { + queryState.data = null; + queryState.isLoading = false; + queryState.isError = false; + queryState.isFetching = false; + queryState.error = null; + queryState.refetch.mockClear(); + scopeState.ref = { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 12 }; + scopeState.isReady = true; + }); + + it('titles the sheet with the provider ref, not a GitHub triple', () => { + expect(find(mountScreen(), 'ScreenHeader').props.eyebrow).toBe('group/sub/repo!12'); + }); + + it('shows one loading indicator while the first load is in flight', () => { + queryState.isLoading = true; + const renderer = mountScreen(); + expect(renderer.root.findAll(node => String(node.type) === 'ActivityIndicator')).toHaveLength( + 1 + ); + expect(renderer.root.findAll(node => String(node.type) === 'QueryError')).toHaveLength(0); + }); + + it('hands the navigator the resolved provider identity on the happy path', () => { + queryState.data = { headSha: 'abc123', counts: { changedFiles: 4 } }; + const navigator = find(mountScreen(), 'PrDiffFileNavigator'); + expect(navigator.props).toMatchObject({ + owner: 'group/sub', + repo: 'repo', + number: 12, + headSha: 'abc123', + changedFiles: 4, + }); + }); + + it('offers a retry only for a transient failure', () => { + queryState.isError = true; + queryState.error = trpcError('INTERNAL_SERVER_ERROR'); + const error = find(mountScreen(), 'QueryError'); + expect(error.props.variant).toBe('server'); + act(() => { + (error.props.onRetry as () => void)(); + }); + expect(queryState.refetch).toHaveBeenCalled(); + }); + + it.each([ + ['FORBIDDEN', 'permission'], + ['NOT_FOUND', 'not-found'], + ])('renders %s as a terminal state with no retry', (code, variant) => { + queryState.isError = true; + queryState.error = trpcError(code); + const error = find(mountScreen(), 'QueryError'); + expect(error.props.variant).toBe(variant); + expect(error.props.onRetry).toBeUndefined(); + }); + + it('points a broken connection at the reconnect notice instead of a retry', () => { + queryState.isError = true; + queryState.error = trpcError('PRECONDITION_FAILED'); + const renderer = mountScreen(); + expect(find(renderer, 'PrReviewReconnectNotice')).toBeDefined(); + expect(renderer.root.findAll(node => String(node.type) === 'QueryError')).toHaveLength(0); + }); +}); diff --git a/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx index 1201218e85..a842acd26d 100644 --- a/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx @@ -6,11 +6,14 @@ import { ActivityIndicator, View } from 'react-native'; import { CenteredState } from '@/components/centered-state'; import { PrDiffFileNavigator } from '@/components/pr-review/diff/pr-diff-file-navigator'; +import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { classifyPrReviewQueryState } from '@/lib/pr-review/classify-pr-review-query-state'; +import { useProviderPrQueries } from '@/lib/pr-review/provider-pr-queries'; +import { providerPrRefLabel, providerPrTriple } from '@/lib/pr-review/provider-pr-ref'; import { parseParam } from '@/lib/route-params'; -import { useTRPC } from '@/lib/trpc'; type Params = { owner: string; @@ -22,29 +25,38 @@ type Params = { * File-navigator formSheet route. Fetches the PR overview for the head SHA * (the navigator keys viewed state + the diff list on it) and mounts the S6c * navigator content. Rendered inside the `[number]` layout's formSheet stack. + * + * Provider-agnostic (s5): the same sheet is a sibling of the provider route's + * stack, where the identity comes from the published scope rather than from + * `owner`/`repo`/`number` params. The GitHub params below stay the fallback + * scope, so the GitHub route reaches the exact same query it did before. */ export function PrReviewFileNavigatorScreen() { const router = useRouter(); const colors = useThemeColors(); const { t } = useTranslation(); const params = useLocalSearchParams(); - const owner = parseParam(params.owner) ?? ''; - const repo = parseParam(params.repo) ?? ''; const rawNumber = parseParam(params.number) ?? ''; - const number = Number.parseInt(rawNumber, 10); + const queries = useProviderPrQueries({ + owner: parseParam(params.owner) ?? '', + repo: parseParam(params.repo) ?? '', + number: Number.parseInt(rawNumber, 10), + }); + const { owner, repo, number } = providerPrTriple(queries.ref); - const trpc = useTRPC(); - const pr = useQuery( - trpc.githubPrReview.getPullRequest.queryOptions( - { owner, repo, number }, - { enabled: Boolean(owner) && Boolean(repo) && Number.isInteger(number) && number > 0 } - ) - ); + // The route layout above validates the identity before this sheet can + // mount; the guard stays as the belt-and-braces it always was, ANDed with + // the scope readiness a Bitbucket ref carries. + const hasIdentity = Boolean(owner) && Boolean(repo) && Number.isInteger(number) && number > 0; + const pr = useQuery({ + ...queries.overviewOptions(), + enabled: queries.isReady && hasIdentity, + }); const header = ( { router.back(); @@ -76,9 +88,8 @@ export function PrReviewFileNavigatorScreen() { ) : ( - { void pr.refetch(); }} @@ -88,3 +99,39 @@ export function PrReviewFileNavigatorScreen() { ); } + +/** + * The sheet's failure states, split the same way the Overview and Discussion + * bodies split them: a permission denial, a missing PR/MR and a broken + * connection are terminal and carry no retry, because retrying the identical + * request cannot change any of them. Only a transient failure gets the CTA. + */ +function NavigatorError({ + error, + onRetry, + isRetrying, +}: Readonly<{ error: unknown; onRetry: () => void; isRetrying: boolean }>) { + const { t } = useTranslation(); + const state = error === null ? null : classifyPrReviewQueryState(error); + if (state?.kind === 'permission') { + return ; + } + if (state?.kind === 'not-found') { + return ; + } + if (state?.kind === 'reconnect') { + return ( + + + + ); + } + return ( + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx b/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx index 87f015aa75..07de0b67b7 100644 --- a/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx @@ -16,6 +16,12 @@ type PrReviewFilesTabProps = { * Files tab: hosts the S6b diff file list (a virtualized FlashList, so the * screen renders this outside its Overview ScrollView). S6c layers the file * navigator sheet and the tablet unified/side-by-side toggle on top of this. + * + * Provider-agnostic (s5): the identity below is the GitHub-shaped triple the + * list and its stores are written against, while the list's own queries + * (`usePrReviewFileListQuery`, `usePrDiffContextLoader`) resolve the real + * `ProviderPrRef` from the provider scope, so the same list renders GitLab + * and Bitbucket diffs without a per-provider copy of this tree. */ export function PrReviewFilesTab({ owner, diff --git a/apps/mobile/src/components/pr-review/pr-review-inbox-list.tsx b/apps/mobile/src/components/pr-review/pr-review-inbox-list.tsx index 8d01d02197..5f9d26e9ab 100644 --- a/apps/mobile/src/components/pr-review/pr-review-inbox-list.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-inbox-list.tsx @@ -24,13 +24,17 @@ import { DirectionalChevronRight } from '@/components/ui/directional-icons'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { getPrReviewPath } from '@/lib/profile-agent-navigation'; -import { usePrInbox } from '@/lib/pr-review/use-pr-inbox'; +import { + providerPrRefLabel, + providerPrRoutePath, + providerPrTermKey, +} from '@/lib/pr-review/provider-pr-ref'; +import { type ProviderInboxRow, useProviderInbox } from '@/lib/pr-review/use-provider-inbox'; import { parseTimestamp, timeAgo } from '@/lib/utils'; const SKELETON_ROW_COUNT = 5; -type InboxItem = ReturnType['items'][number]; +type InboxItem = ProviderInboxRow; type PrReviewInboxListProps = { /** The "Paste a PR link" block, rendered above the Inbox eyebrow. */ @@ -40,18 +44,33 @@ type PrReviewInboxListProps = { }; export function PrReviewInboxList({ header, recents }: Readonly) { - const { query, items, firstPageErrorState, laterPageError } = usePrInbox(true); + const inbox = useProviderInbox(true); + // Two different retries, because they recover two different failures: the + // empty-state CTA re-runs the inbox from scratch, while the footer CTA must + // load only the page (or the provider) that failed — re-fetching pages the + // list already shows would never load the missing one. + const handleRetry = () => { + inbox.refetch(); + }; + const handleRetryMore = () => { + inbox.retryFailedPages(); + }; const view = selectPrInboxView({ - isLoading: query.isPending, - itemCount: items.length, - firstPageErrorState, - laterPageError, + isLoading: inbox.isPending, + itemCount: inbox.items.length, + firstPageErrorState: inbox.firstPageErrorState, + laterPageError: inbox.laterPageError, }); + // A provider outage while the merged list happens to be empty is still a + // retryable failure, not "no review requests": keep the footer retry so the + // failing provider has a CTA the empty state itself must not carry. + const showLoadMoreRetry = + view.showLoadMoreRetry || (view.kind === 'empty' && inbox.laterPageError); return ( `${item.owner}/${item.repo}#${item.number}`} + data={view.kind === 'happy' ? inbox.items : []} + keyExtractor={item => item.key} renderItem={({ item }) => } ListHeaderComponent={ @@ -60,30 +79,18 @@ export function PrReviewInboxList({ header, recents }: Readonly } ListEmptyComponent={ - { - void query.refetch(); - }} - isRetrying={query.isFetching} - /> + } ListFooterComponent={ - {view.showLoadMoreRetry ? ( - { - void query.fetchNextPage(); - }} - /> - ) : null} + {showLoadMoreRetry ? : null} {recents} } onEndReached={() => { - if (query.hasNextPage && !query.isFetchingNextPage) { - void query.fetchNextPage(); + if (inbox.hasNextPage && !inbox.isFetchingNextPage) { + inbox.fetchNextPage(); } }} onEndReachedThreshold={0.5} @@ -124,12 +131,14 @@ function InboxRow({ item }: Readonly<{ item: InboxItem }>) { const colors = useThemeColors(); const { t } = useTranslation(); const updatedLabel = timeAgo(parseTimestamp(item.updatedAt)); - const rowLabel = `${item.owner}/${item.repo}#${item.number}`; + // `group/sub/repo!12` on GitLab, `owner/repo#7` elsewhere — the row says + // which provider it came from before the term chip repeats it in words. + const rowLabel = providerPrRefLabel(item.ref); return ( { - router.push(getPrReviewPath(item.owner, item.repo, item.number)); + router.push(providerPrRoutePath(item.ref)); }} accessibilityRole="button" accessibilityLabel={rowLabel} @@ -141,15 +150,10 @@ function InboxRow({ item }: Readonly<{ item: InboxItem }>) { - {item.owner}/{item.repo}#{item.number} · {updatedLabel} + {rowLabel} · {updatedLabel} - {item.isDraft ? ( - - - {t('common.draft')} - - - ) : null} + + {item.isDraft ? : null} @@ -157,6 +161,16 @@ function InboxRow({ item }: Readonly<{ item: InboxItem }>) { ); } +function InboxChip({ label }: Readonly<{ label: string }>) { + return ( + + + {label} + + + ); +} + function InboxEmpty({ view, onRetry, diff --git a/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx b/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx index 6e982720af..832fac0fa6 100644 --- a/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx @@ -180,7 +180,8 @@ export function PrCountsLine({ additions, deletions, }: Readonly<{ - commits: number; + /** Null where the provider reports no commit count; the chip is dropped. */ + commits: number | null; changedFiles: number; additions: number; deletions: number; @@ -189,12 +190,15 @@ export function PrCountsLine({ const { t } = useTranslation(); return ( - - - - {formatNumber(commits, i18n.language)} {t('prReview.overview.commit', { count: commits })} - - + {commits === null ? null : ( + + + + {formatNumber(commits, i18n.language)}{' '} + {t('prReview.overview.commit', { count: commits })} + + + )} diff --git a/apps/mobile/src/components/pr-review/pr-review-overview.tsx b/apps/mobile/src/components/pr-review/pr-review-overview.tsx index 0647bf47bc..37f3c5f821 100644 --- a/apps/mobile/src/components/pr-review/pr-review-overview.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-overview.tsx @@ -28,8 +28,9 @@ import { getGitHubIntegrationUrl } from '@/lib/agent-github-integration'; import { WEB_BASE_URL } from '@/lib/config'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { classifyPrReviewQueryState } from '@/lib/pr-review/classify-pr-review-query-state'; +import { useProviderPrQueries } from '@/lib/pr-review/provider-pr-queries'; import { useCheckGitHubConnection } from '@/lib/pr-review/use-check-github-connection'; -import { trpcClient, useTRPC } from '@/lib/trpc'; +import { trpcClient } from '@/lib/trpc'; const REVIEW_SUBMIT_PATH = '/(app)/pr-review/[owner]/[repo]/[number]/review-submit' as const; @@ -76,13 +77,19 @@ export function PrReviewOverview({ isActive: _isActive, refreshControl, }: PrReviewOverviewProps) { - const trpc = useTRPC(); + const queries = useProviderPrQueries({ owner, repo, number }); const connection = useCheckGitHubConnection(); const router = useRouter(); const colors = useThemeColors(); const { t } = useTranslation(); - const pr = useQuery(trpc.githubPrReview.getPullRequest.queryOptions({ owner, repo, number })); + // GitHub-only affordances: the install/reconnect CTAs and the review-submit + // sheet are GitHub App flows and GitHub-route siblings. On GitLab and + // Bitbucket the same states render without a CTA that cannot work. + const isGitHub = queries.platform === 'github'; + const isMergeRequest = queries.platform === 'gitlab'; + + const pr = useQuery(queries.overviewOptions()); const handleOpenReviewSubmit = useCallback(() => { const href: Href = { @@ -112,12 +119,22 @@ export function PrReviewOverview({ - {t('prReview.installKiloGitHubApp')} - + isGitHub ? ( + + ) : null } /> ); @@ -129,7 +146,11 @@ export function PrReviewOverview({ refreshControl={refreshControl} icon={GitPullRequest} title={t('common.accessDenied')} - description={t('prReview.accessDeniedDescription')} + description={ + isMergeRequest + ? t('prReview.terms.accessDeniedMergeRequest') + : t('prReview.accessDeniedDescription') + } /> ); } @@ -141,15 +162,17 @@ export function PrReviewOverview({ title={t('prReview.reconnectNotice.title')} description={t('prReview.reconnectNotice.message')} action={ - + isGitHub ? ( + + ) : null } /> ); @@ -159,7 +182,11 @@ export function PrReviewOverview({ { void pr.refetch(); }} @@ -232,30 +259,34 @@ export function PrReviewOverview({ - - - {t('prReview.review')} - - - + {isGitHub ? ( + + + {t('prReview.review')} + + + + ) : null} - { - await pr.refetch(); - }} - isRefetching={pr.isFetching} - /> + {isGitHub ? ( + { + await pr.refetch(); + }} + isRefetching={pr.isFetching} + /> + ) : null} {t('prReview.headLine', { diff --git a/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx b/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx index f1b18ae573..dd1f2bb995 100644 --- a/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx @@ -18,6 +18,7 @@ import '@/i18n'; import type * as ReactI18next from 'react-i18next'; import { PrReviewScreen } from './pr-review-screen'; import { type PendingReviewItem } from '@/lib/pr-review/pending-review-provider'; +import { markRecentPrFailed, upsertRecentPr } from '@/lib/pr-review/recent-prs'; vi.mock('react-i18next', async importOriginal => { const actual = await importOriginal(); @@ -43,6 +44,9 @@ vi.mock('react', async () => { (initial: T) => [initial, vi.fn() as () => void] as [T, (value: T) => void] ), useMemo: vi.fn((factory: () => T) => factory()), + // The provider scope context is only mounted by the provider route; the + // GitHub route reads the fallback triple, which a null context selects. + useContext: vi.fn(() => null), useRef: vi.fn((initial: T) => { const ref: React.RefObject = { current: initial }; return ref; @@ -83,9 +87,12 @@ vi.mock('@tanstack/react-query', () => ({ vi.mock('@/components/ui/icons', () => ({ Check: () => null, + GitPullRequest: () => null, Share: () => null, })); +vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); + vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({ primaryForeground: '#FFFFFF', @@ -97,8 +104,13 @@ vi.mock('@/lib/hooks/use-theme-colors', () => ({ vi.mock('@/lib/trpc', () => ({ useTRPC: () => ({ githubPrReview: { - getPullRequest: { queryOptions: () => ({}), queryKey: () => [] }, - listChecks: { queryKey: () => [] }, + getPullRequest: { queryOptions: () => ({ queryKey: [] }) }, + listChecks: { queryOptions: () => ({ queryKey: [] }) }, + }, + providerReview: { + getPullRequest: { queryOptions: () => ({ queryKey: [] }) }, + listChecks: { queryOptions: () => ({ queryKey: [] }) }, + getMergeState: { queryOptions: () => ({ queryKey: [] }) }, }, githubApps: { getUserAuthorization: { queryKey: () => [] } }, }), @@ -110,6 +122,7 @@ vi.mock('@/lib/pr-review/merge/merge-result-banner-store', () => ({ vi.mock('@/lib/pr-review/recent-prs', () => ({ upsertRecentPr: vi.fn(), + markRecentPrFailed: vi.fn(), })); vi.mock('@/components/screen-header', () => { @@ -373,3 +386,70 @@ describe('PrReviewScreen Overview scrolling', () => { expect((refresh.props as { onRefresh: unknown }).onRefresh).toEqual(expect.any(Function)); }); }); + +// The recents store is keyed on `owner/repo#number` and its rows navigate to +// the GitHub route, so only a GitHub pull request may be written there — a +// GitLab MR filed under that key would send the user to a GitHub PR on the +// way back. This describe is the only one that runs effects: the rest of the +// file asserts render output, where the recents backfill is noise. +describe('PrReviewScreen recents backfill per provider', () => { + const GITLAB_SCOPE = { + ref: { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 12 }, + organizationId: null, + }; + + beforeEach(() => { + vi.mocked(upsertRecentPr).mockClear(); + vi.mocked(markRecentPrFailed).mockClear(); + vi.mocked(React.useEffect).mockImplementation((effect: React.EffectCallback) => { + effect(); + }); + }); + + afterEach(() => { + vi.mocked(React.useEffect).mockImplementation(() => undefined); + vi.mocked(React.useContext).mockReturnValue(null); + }); + + it('writes a recents entry for a loaded GitHub pull request', () => { + prQueryResult = { + data: { title: 'Fix the thing' }, + isLoading: false, + isError: false, + isFetching: false, + }; + // eslint-disable-next-line new-cap + PrReviewScreen({ owner: 'octocat', repo: 'hello', number: 7 }); + expect(upsertRecentPr).toHaveBeenCalledWith( + expect.objectContaining({ owner: 'octocat', repo: 'hello', number: 7, lastResult: 'ok' }) + ); + }); + + it('marks the GitHub entry failed when the load errors', () => { + prQueryResult = { data: undefined, isLoading: false, isError: true, isFetching: false }; + // eslint-disable-next-line new-cap + PrReviewScreen({ owner: 'octocat', repo: 'hello', number: 7 }); + expect(markRecentPrFailed).toHaveBeenCalledWith({ owner: 'octocat', repo: 'hello', number: 7 }); + }); + + it('never files a loaded GitLab merge request under a GitHub triple', () => { + vi.mocked(React.useContext).mockReturnValue(GITLAB_SCOPE); + prQueryResult = { + data: { title: 'Bump the dep' }, + isLoading: false, + isError: false, + isFetching: false, + }; + // eslint-disable-next-line new-cap + PrReviewScreen({ owner: 'group/sub', repo: 'repo', number: 12 }); + expect(upsertRecentPr).not.toHaveBeenCalled(); + }); + + it('never marks a GitLab merge request failed in the GitHub recents store', () => { + vi.mocked(React.useContext).mockReturnValue(GITLAB_SCOPE); + prQueryResult = { data: undefined, isLoading: false, isError: true, isFetching: false }; + // eslint-disable-next-line new-cap + PrReviewScreen({ owner: 'group/sub', repo: 'repo', number: 12 }); + expect(markRecentPrFailed).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/components/pr-review/pr-review-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-screen.tsx index 4e62eb99d0..1e19cd3b61 100644 --- a/apps/mobile/src/components/pr-review/pr-review-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-screen.tsx @@ -1,6 +1,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { type Href, useFocusEffect, useRouter } from 'expo-router'; -import { Check, Share as ShareIcon } from '@/components/ui/icons'; +import { Check, GitPullRequest, Share as ShareIcon } from '@/components/ui/icons'; import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, RefreshControl, Share, View } from 'react-native'; @@ -13,13 +13,15 @@ import { type PrReviewTabId, PrReviewTabSelector, } from '@/components/pr-review/pr-review-tab-selector'; +import { EmptyState } from '@/components/empty-state'; import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { consumeMergePartialSuccess } from '@/lib/pr-review/merge/merge-result-banner-store'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { useProviderPrQueries } from '@/lib/pr-review/provider-pr-queries'; +import { providerPrWebUrl } from '@/lib/pr-review/provider-pr-ref'; import { markRecentPrFailed, upsertRecentPr } from '@/lib/pr-review/recent-prs'; -import { useTRPC } from '@/lib/trpc'; import { cn } from '@/lib/utils'; const REVIEW_SUBMIT_PATH = '/(app)/pr-review/[owner]/[repo]/[number]/review-submit' as const; @@ -38,6 +40,12 @@ type PrReviewScreenProps = { * - the recents title backfill (upsertRecentPr with the real title * on the first successful `getPullRequest`). * + * Provider parameterization (s5): the screen reads its identity from the + * provider scope published by the route (`useProviderPrQueries`), so the same + * tree renders a GitHub pull request, a GitLab merge request and a Bitbucket + * pull request. The `owner`/`repo`/`number` props stay the GitHub route's + * contract and are the fallback scope when no provider route is above. + * * The screen intentionally fetches the PR DTO once and passes the * `headSha` and `changedFiles` down to the Files tab so the placeholder * can show useful info and S6b can drop in without a new fetch layer. @@ -46,7 +54,7 @@ type PrReviewScreenProps = { * `PrReviewOverview`. */ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) { - const trpc = useTRPC(); + const queries = useProviderPrQueries({ owner, repo, number }); const queryClient = useQueryClient(); const router = useRouter(); const colors = useThemeColors(); @@ -84,15 +92,23 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) { // and pass `headSha` / `changedFiles` to the Files tab. The Overview // re-uses the same query — tanstack-query dedupes by key, so this is // a single network round-trip even though both components subscribe. - const pr = useQuery(trpc.githubPrReview.getPullRequest.queryOptions({ owner, repo, number })); + const overviewOptions = queries.overviewOptions(); + const pr = useQuery(overviewOptions); // Recents backfill. This is the ONLY writer that creates an entry: a // successful load upserts the real title with `lastResult: 'ok'`, which // also clears any previous `'failed'` marker. A never-authorized PR // (no successful load) never gets an entry. + // + // GitHub only: the recents store is keyed on the `owner/repo#number` + // triple and its rows navigate to the GitHub route, so writing a GitLab MR + // or a Bitbucket PR there would file it under a GitHub identity and send + // the user to a GitHub pull request on the way back. A provider ref stays + // out of recents until the store carries one. + const isGitHub = queries.platform === 'github'; useEffect(() => { const data = pr.data; - if (!data?.title) { + if (!isGitHub || !data?.title) { return; } void upsertRecentPr({ @@ -103,7 +119,7 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) { lastOpenedAt: Date.now(), lastResult: 'ok', }); - }, [pr.data, owner, repo, number]); + }, [pr.data, isGitHub, owner, repo, number]); // Mark an existing recents entry as failed exactly once per error. The // ref guards against re-writing on re-render; `markRecentPrFailed` is a @@ -116,43 +132,47 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) { markedFailedRef.current = false; return; } - if (markedFailedRef.current) { + if (markedFailedRef.current || !isGitHub) { return; } markedFailedRef.current = true; void markRecentPrFailed({ owner, repo, number }); - }, [pr.isError, owner, repo, number]); + }, [pr.isError, isGitHub, owner, repo, number]); // Share the PR's public GitHub URL via the native share sheet. The URL comes // from the route params, so this works before the PR query resolves; the title // is added once it is known. Fire-and-forget, like the invite-link share in // `invited-member-row.tsx` — cancelling resolves with `dismissedAction`, and a // sheet the platform refuses to present has no actionable recovery. + // Null on a GitLab ref reached without an instance hint: there is no host to + // build a public link from, so the affordance is hidden rather than sharing + // a link into some other instance's project. + const webUrl = providerPrWebUrl(queries.ref); const sharePullRequest = useCallback(() => { - const url = `https://github.com/${owner}/${repo}/pull/${number}`; + if (!webUrl) { + return; + } const title = pr.data?.title; - void Share.share({ message: title ? `${title}\n${url}` : url }); - }, [owner, repo, number, pr.data?.title]); + void Share.share({ message: title ? `${title}\n${webUrl}` : webUrl }); + }, [webUrl, pr.data?.title]); const handleRefresh = useCallback(() => { void (async () => { setRefreshing(true); try { const headSha = pr.data?.headSha; + // The keys are read off fresh builder calls rather than off the + // render-scope `overviewOptions`: a builder returns a new options + // object (and a new key array) every call, so closing over one would + // make this callback churn on every render for no behaviour change. await Promise.all([ - queryClient.invalidateQueries({ - queryKey: trpc.githubPrReview.getPullRequest.queryKey({ - owner, - repo, - number, - }), - }), + queryClient.invalidateQueries({ queryKey: queries.overviewOptions().queryKey }), // Only invalidate checks when we know the head SHA; invalidating with // an empty ref would target a key that never matches the live query. ...(headSha ? [ queryClient.invalidateQueries({ - queryKey: trpc.githubPrReview.listChecks.queryKey({ owner, repo, ref: headSha }), + queryKey: queries.checksOptions(headSha).queryKey, }), ] : []), @@ -161,10 +181,27 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) { setRefreshing(false); } })(); - }, [queryClient, trpc, owner, repo, number, pr.data?.headSha]); + }, [queryClient, queries, pr.data?.headSha]); + + const isMergeRequest = queries.platform === 'gitlab'; + // The review-submit sheet is a GitHub-route sibling; the provider write + // surfaces land with the provider write slice, so the affordance is only + // offered where it can actually be reached. + const canSubmitReview = isGitHub; let body: ReactNode = null; - if (tab === 'overview') { + if (!queries.isReady) { + // Non-retryable: Bitbucket Cloud review is organization-scoped and no + // organization is selected. Nothing on this screen can fix that, so the + // state names where the switch lives instead of offering a dead retry. + body = ( + + ); + } else if (tab === 'overview') { body = ( <> {partialMergeReason ? : null} @@ -206,23 +243,33 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) { return ( - - - + {webUrl ? ( + + + + ) : null} {/* P1-F-46b: the Submit-review affordance is reachable from the Overview tab (header right) and the Files tab (floating action bar). The Discussion tab is intentionally left without a submit affordance — comment threads there are read-only. */} - {tab === 'overview' ? ( + {tab === 'overview' && canSubmitReview ? ( + {autoMerge.supported ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.test.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.test.tsx new file mode 100644 index 0000000000..e73c0272cf --- /dev/null +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.test.tsx @@ -0,0 +1,121 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom); see src/lib/pr-review/pending-review-provider.mounted.test.tsx */ +// MergeSheetFormBody field contract (s6f): the Bitbucket merge arm passes +// showTitle=false because the provider merge takes only the message — no +// commit-title input may exist on that arm whose value would be silently +// dropped on submit. The GitLab and GitHub arms keep the field. + +import * as React from 'react'; +import TestRenderer from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import '@/i18n'; +import type * as ReactI18next from 'react-i18next'; +import { MergeSheetFormBody } from './pr-merge-sheet-parts'; +import { type AllowedMergeMethod } from '@/lib/pr-review/merge/merge-blocked-reasons'; + +vi.mock('react-i18next', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => { + const i18n = actual.getI18n(); + return { t: i18n.t.bind(i18n), i18n }; + }, + }; +}); + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + Switch: 'Switch', + TextInput: 'TextInput', + View: 'View', +})); + +vi.mock('expo-haptics', () => ({ + selectionAsync: vi.fn(), +})); + +vi.mock('@/components/pr-review/pr-form-sheet-chrome', () => ({ + PrFormSheetFooter: 'PrFormSheetFooter', + useFormSheetKeyboardVisible: () => false, +})); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/radio-group', () => ({ + RadioGroup: 'RadioGroup', + radioItemA11y: (props: unknown) => props, +})); +vi.mock('@/components/ui/accessible-status', () => ({ AccessibleStatus: 'AccessibleStatus' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#000000' }), +})); +vi.mock('@/components/pr-review/pr-review-reconnect-notice', () => ({ + PrReviewReconnectNotice: 'PrReviewReconnectNotice', +})); + +function formBodyProps(showTitle: boolean) { + const titleRef = { current: 'Merge pull request #1 from Feature' }; + const messageRef = { current: '' }; + return { + noMethodsAllowed: false, + methodOptions: [{ value: 'merge' as AllowedMergeMethod, label: 'Merge', icon: 'merge' }], + method: 'merge' as AllowedMergeMethod, + isMutating: false, + onMethodChange: vi.fn(), + titleRef, + titleInputRef: { current: null }, + titlePlaceholder: 'Merge pull request #1 from Feature', + showTitle, + messageRef, + messageInputRef: { current: null }, + isHalfDetent: false, + showDeleteBranchToggle: false, + deleteBranch: false, + onDeleteBranchChange: vi.fn(), + inlineError: null, + inlineErrorKind: null, + submitLabel: 'Merge', + onConfirm: vi.fn(), + onDismiss: vi.fn(), + }; +} + +function inputByA11yLabel( + renderer: TestRenderer.ReactTestRenderer, + label: string +): TestRenderer.ReactTestInstance | null { + const found = renderer.root.findAll(node => node.props.accessibilityLabel === label); + return found[0] ?? null; +} + +describe('MergeSheetFormBody commit-title field (s6f)', () => { + it('renders no commit-title input when showTitle is false (Bitbucket arm)', () => { + let renderer: TestRenderer.ReactTestRenderer | undefined = undefined; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- the guard proves it, the cast cannot + if (!renderer) { + throw new Error('Failed to mount MergeSheetFormBody'); + } + expect(inputByA11yLabel(renderer, 'Commit title')).toBeNull(); + // The message field stays: the Bitbucket merge takes the message. + expect(inputByA11yLabel(renderer, 'Commit message')).not.toBeNull(); + }); + + it('keeps the commit-title input when showTitle is true (GitLab and GitHub arms)', () => { + let renderer: TestRenderer.ReactTestRenderer | undefined = undefined; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- the guard proves it, the cast cannot + if (!renderer) { + throw new Error('Failed to mount MergeSheetFormBody'); + } + const title = inputByA11yLabel(renderer, 'Commit title'); + if (!title) { + throw new Error('Commit title input not found'); + } + expect(title.props.defaultValue).toBe('Merge pull request #1 from Feature'); + }); +}); diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx index a578c3529e..2d4b1d7449 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx @@ -205,6 +205,12 @@ export function MergeSheetFormBody(props: { titleRef: RefObject; titleInputRef: RefObject; titlePlaceholder: string; + /** + * False on the Bitbucket merge arm (s6f): the provider merge takes only + * the message, so no commit-title input exists whose value would be + * silently dropped on submit. + */ + showTitle: boolean; messageRef: RefObject; messageInputRef: RefObject; isHalfDetent: boolean; @@ -226,6 +232,7 @@ export function MergeSheetFormBody(props: { titleRef, titleInputRef, titlePlaceholder, + showTitle, messageRef, messageInputRef, isHalfDetent, @@ -260,12 +267,14 @@ export function MergeSheetFormBody(props: { onChange={onMethodChange} /> )} - + {showTitle ? ( + + ) : null} ({ error: null as Error | null, })); +// Every useState setter the mocked hook primitives hand out, in call order. +// The error effect writes its inline copy through one of them, so a test can +// observe the state write even though the no-op mock never re-renders. +const stateSetters = vi.hoisted(() => [] as { mock: { calls: unknown[][] } }[]); + vi.mock('react', async () => { const actual = await vi.importActual('react'); return { ...actual, - useState: vi.fn( - (initial: T) => [initial, vi.fn() as () => void] as [T, (value: T) => void] - ), + useState: vi.fn((initial: T) => { + const setter = vi.fn(); + stateSetters.push(setter); + return [initial, setter as () => void] as [T, (value: T) => void]; + }), useMemo: vi.fn((factory: () => T) => factory()), useRef: vi.fn((initial: T) => { const ref: React.RefObject = { current: initial }; @@ -57,14 +75,17 @@ vi.mock('react', async () => { }; }); +const alertCalls = vi.hoisted(() => [] as { title: string; message: string }[]); + vi.mock('react-native', () => ({ Alert: { alert: vi.fn( ( - _title: string, - _message: string, + title: string, + message: string, buttons: readonly { style?: string; onPress?: () => void }[] ) => { + alertCalls.push({ title, message }); const destructive = buttons.find(b => b.style === 'destructive'); destructive?.onPress?.(); } @@ -117,6 +138,7 @@ vi.mock('@/components/pr-review/pr-review-reconnect-notice', () => ({ })); vi.mock('@/components/pr-review/pr-form-sheet-chrome', () => ({ PrFormSheetHeader: 'PrFormSheetHeader', + PrFormSheetFooter: 'PrFormSheetFooter', })); vi.mock('@/components/pr-review/merge/pr-merge-icons', () => ({ defaultMergeMethodOptionFor: () => 'squash', @@ -239,7 +261,7 @@ function findElement({ node, type, prop, value }: FindElementArgs): React.ReactE return null; } -function pressMerge(props: typeof baseProps) { +function pressMerge(props: Parameters[0]) { // eslint-disable-next-line new-cap const element = PrMergeSheet(props); // The submit CTA lives inside MergeSheetFormBody (mocked as a string @@ -388,3 +410,504 @@ describe('PrMergeSheet performSubmit wiring (P0-B-08)', () => { expect(onDismiss).toHaveBeenCalledTimes(1); }); }); + +// ── Provider arms (s6) ─────────────────────────────────────────────── + +/** Find an element whose type is a real (unmocked) component — by identity. */ +function findComponent(node: unknown, component: unknown): React.ReactElement | null { + if (React.isValidElement(node)) { + if (node.type === component) { + return node; + } + const children = (node.props as Record).children; + const found = findComponent(children, component); + if (found) { + return found; + } + } + if (Array.isArray(node)) { + for (const child of node) { + const found = findComponent(child, component); + if (found) { + return found; + } + } + } + return null; +} + +/** findComponent, but fails the test loudly when the component is absent. */ +function requireComponent(node: unknown, component: unknown): React.ReactElement { + const found = findComponent(node, component); + if (!found) { + throw new Error(`component ${(component as { name?: string }).name ?? '?'} not found`); + } + return found; +} + +/** Every string a directly-invoked component function rendered, in order. */ +function collectTexts(node: unknown): string[] { + const out: string[] = []; + const walk = (value: unknown): void => { + if (typeof value === 'string') { + out.push(value); + return; + } + if (Array.isArray(value)) { + for (const child of value) { + walk(child); + } + return; + } + if (React.isValidElement(value)) { + walk((value.props as Record).children); + } + }; + walk(node); + return out; +} + +const GITLAB_REF: ProviderPrRef = { platform: 'gitlab', projectPath: 'octocat/hello', mrIid: 1 }; +const BITBUCKET_REF: ProviderPrRef = { + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'hello', + prId: 1, +}; + +function mergeState(overrides: Partial = {}): ProviderPrMergeState { + return { + canMerge: true, + approvalsRequired: 0, + pipelineMustSucceed: false, + conflicts: false, + blockedReasons: [], + ...overrides, + }; +} + +const AUTO_MERGE_SUPPORTED: ProviderReviewCapability = { supported: true, reason: '' }; +const AUTO_MERGE_UNSUPPORTED: ProviderReviewCapability = { + supported: false, + reason: 'Bitbucket Cloud does not expose auto-merge in its API', +}; + +describe('PrMergeSheet provider merge arm (s6)', () => { + beforeEach(() => { + alertCalls.length = 0; + __resetMergePartialSuccessStoreForTests(); + mergeMutationMocks.mutateAsync.mockReset(); + mergeMutationMocks.isPending = false; + mergeMutationMocks.error = null; + autoMergeMutationMocks.mutateAsync.mockReset(); + autoMergeMutationMocks.isPending = false; + autoMergeMutationMocks.error = null; + vi.clearAllMocks(); + }); + + it('fences the merge on the head and folds the method into squash for GitLab', () => { + mergeMutationMocks.mutateAsync.mockResolvedValueOnce({ + merged: true, + sha: 'mergedsha', + branchDeleted: true, + }); + pressMerge({ ...baseProps, prRef: GITLAB_REF, mergeState: mergeState() }); + + // The confirm dialog speaks the connected provider's noun, in sentence + // form: "Merge merge request?", never "Merge Merge request?". + expect(alertCalls[0]).toEqual({ + title: 'Merge merge request?', + message: 'This will merge your changes into the base branch.', + }); + expect(mergeMutationMocks.mutateAsync).toHaveBeenCalledWith({ + expectedHeadSha: 'a'.repeat(40), + squash: true, + deleteBranch: true, + // The commit-title field rides the merge: GitLab takes the title the + // user sees (here the mocked default). + commitTitle: 'Merge pull request #1 from Feature', + }); + }); + + it('keeps the GitHub arm confirm copy and full input unchanged', () => { + mergeMutationMocks.mutateAsync.mockResolvedValueOnce({ + merged: true, + sha: 'mergedsha', + branchDeleted: true, + }); + pressMerge(baseProps); + + expect(alertCalls[0]?.title).toBe('Merge pull request?'); + expect(mergeMutationMocks.mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + owner: 'octocat', + repo: 'hello', + number: 1, + method: 'squash', + expectedHeadSha: 'a'.repeat(40), + }) + ); + }); + + it('folds the provider ref identity into the durable merge draft key', async () => { + mergeMutationMocks.mutateAsync.mockResolvedValueOnce({ + merged: true, + sha: 'mergedsha', + branchDeleted: true, + }); + pressMerge({ ...baseProps, prRef: GITLAB_REF, mergeState: mergeState() }); + await flushMicrotasks(); + + expect(clearDraft).toHaveBeenCalledWith( + 'u1', + `pr-merge:octocat/hello#1@${providerPrRefKey(GITLAB_REF)}` + ); + }); + + it('shows the restrictions list above the form when the merge is allowed', () => { + // eslint-disable-next-line new-cap + const element = PrMergeSheet({ + ...baseProps, + prRef: GITLAB_REF, + mergeState: mergeState({ approvalsRequired: 2, pipelineMustSucceed: true }), + }); + expect( + findElement({ + node: element, + type: 'MergeSheetFormBody', + prop: 'submitLabel', + value: 'Merge', + }) + ).not.toBeNull(); + expect(findComponent(element, MergeRestrictionsList)).not.toBeNull(); + }); + + it('replaces the form with the restrictions list when the merge is blocked', () => { + // eslint-disable-next-line new-cap + const element = PrMergeSheet({ + ...baseProps, + prRef: GITLAB_REF, + mergeState: mergeState({ + canMerge: false, + blockedReasons: [{ code: 'failing_pipeline', message: 'Pipeline #123 failed' }], + }), + }); + // Nothing to submit: the form is gone and only Cancel remains. + expect( + findElement({ + node: element, + type: 'MergeSheetFormBody', + prop: 'submitLabel', + value: 'Merge', + }) + ).toBeNull(); + expect(findComponent(element, MergeRestrictionsList)).not.toBeNull(); + expect( + findElement({ node: element, type: 'Button', prop: 'accessibilityLabel', value: 'Cancel' }) + ).not.toBeNull(); + }); +}); + +// ── s6f: reviewer blocking findings ────────────────────────────────── + +function forbiddenError(): Error { + return Object.assign(new Error('403 Forbidden'), { data: { code: 'FORBIDDEN' } }); +} + +/** True when the error effect wrote `value` into any state slot. */ +function stateValueWritten(value: string): boolean { + return stateSetters.some(setter => setter.mock.calls.some(call => call[0] === value)); +} + +describe('PrMergeSheet commit-title arm (s6f)', () => { + beforeEach(() => { + stateSetters.length = 0; + mergeMutationMocks.mutateAsync.mockReset(); + mergeMutationMocks.isPending = false; + mergeMutationMocks.error = null; + vi.clearAllMocks(); + }); + + function formBodyProps(props: Parameters[0]) { + // eslint-disable-next-line new-cap + const element = PrMergeSheet(props); + const formBody = findElement({ + node: element, + type: 'MergeSheetFormBody', + prop: 'submitLabel', + value: 'Merge', + }); + if (!formBody) { + throw new Error('MergeSheetFormBody not found in rendered tree'); + } + return formBody.props as { showTitle?: boolean }; + } + + it('hides the commit-title field on the Bitbucket arm (the provider takes only the message)', () => { + expect( + formBodyProps({ ...baseProps, prRef: BITBUCKET_REF, mergeState: mergeState() }).showTitle + ).toBe(false); + }); + + it('keeps the commit-title field on the GitLab and GitHub arms', () => { + expect( + formBodyProps({ ...baseProps, prRef: GITLAB_REF, mergeState: mergeState() }).showTitle + ).toBe(true); + expect(formBodyProps(baseProps).showTitle).toBe(true); + }); + + it('never carries a commitTitle on the Bitbucket merge input', () => { + mergeMutationMocks.mutateAsync.mockResolvedValueOnce({ + merged: true, + sha: 'mergedsha', + branchDeleted: true, + }); + pressMerge({ ...baseProps, prRef: BITBUCKET_REF, mergeState: mergeState() }); + expect(mergeMutationMocks.mutateAsync).toHaveBeenCalledWith( + expect.not.objectContaining({ commitTitle: expect.anything() }) + ); + }); +}); + +describe('PrMergeSheet refused-merge wording (s6f)', () => { + beforeEach(() => { + stateSetters.length = 0; + mergeMutationMocks.mutateAsync.mockReset(); + mergeMutationMocks.isPending = false; + mergeMutationMocks.error = null; + vi.clearAllMocks(); + }); + + it('words a refused GitLab merge after the merge-request noun', () => { + mergeMutationMocks.error = forbiddenError(); + // eslint-disable-next-line new-cap + PrMergeSheet({ ...baseProps, prRef: GITLAB_REF, mergeState: mergeState() }); + // The provider 403 must never read "pull request" on a merge request. + expect(stateValueWritten("You don't have permission to merge this merge request.")).toBe(true); + expect(stateValueWritten("You don't have permission to merge this pull request.")).toBe(false); + }); + + it('keeps the exact pre-s6 forbidden copy on the GitHub arm', () => { + mergeMutationMocks.error = forbiddenError(); + // eslint-disable-next-line new-cap + PrMergeSheet(baseProps); + expect(stateValueWritten("You don't have permission to merge this pull request.")).toBe(true); + }); +}); + +describe('PrMergeSheet provider auto-merge arms (s6)', () => { + beforeEach(() => { + alertCalls.length = 0; + __resetMergePartialSuccessStoreForTests(); + mergeMutationMocks.mutateAsync.mockReset(); + autoMergeMutationMocks.mutateAsync.mockReset(); + autoMergeMutationMocks.isPending = false; + autoMergeMutationMocks.error = null; + vi.clearAllMocks(); + }); + + function autoMergeProps(ref: ProviderPrRef, capability: ProviderReviewCapability) { + return { + ...baseProps, + mode: 'enable-auto-merge' as const, + prRef: ref, + mergeState: mergeState({ approvalsRequired: 2 }), + autoMergeCapability: capability, + }; + } + + function pressSubmit(element: React.ReactElement): () => void { + const submit = findElement({ + node: element, + type: 'Button', + prop: 'accessibilityLabel', + value: 'Enable auto-merge', + }); + if (!submit) { + throw new Error('auto-merge submit button not found'); + } + const onPress = (submit.props as { onPress?: () => void }).onPress; + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- the guard above proves it, the cast cannot + if (!onPress) { + throw new Error('auto-merge submit has no onPress'); + } + return onPress; + } + + it('arms GitLab auto-merge through the head fence with the provider confirm copy', () => { + autoMergeMutationMocks.mutateAsync.mockResolvedValueOnce({ supported: true, reason: '' }); + // eslint-disable-next-line new-cap + const element = PrMergeSheet(autoMergeProps(GITLAB_REF, AUTO_MERGE_SUPPORTED)); + expect(findComponent(element, ProviderAutoMergeBody)).not.toBeNull(); + + pressSubmit(element)(); + expect(alertCalls[0]?.message).toBe( + 'The merge request will merge automatically once its pipeline succeeds.' + ); + expect(autoMergeMutationMocks.mutateAsync).toHaveBeenCalledWith({ + expectedHeadSha: 'a'.repeat(40), + }); + }); + + it('shows the capability banner with the provider reason for Bitbucket, with nothing to submit', () => { + // eslint-disable-next-line new-cap + const element = PrMergeSheet(autoMergeProps(BITBUCKET_REF, AUTO_MERGE_UNSUPPORTED)); + // The banner carries the server's explicit reason; no submit CTA exists. + const banner = requireComponent(element, PrReviewCapabilityBanner); + expect((banner.props as { capability?: ProviderReviewCapability }).capability).toBe( + AUTO_MERGE_UNSUPPORTED + ); + expect( + findElement({ + node: element, + type: 'Button', + prop: 'accessibilityLabel', + value: 'Enable auto-merge', + }) + ).toBeNull(); + expect(autoMergeMutationMocks.mutateAsync).not.toHaveBeenCalled(); + }); +}); + +describe('MergeRestrictionsList (s6)', () => { + it('lists the policy flags in localized copy', () => { + // eslint-disable-next-line new-cap + const tree = MergeRestrictionsList({ + mergeState: mergeState({ + canMerge: false, + approvalsRequired: 2, + pipelineMustSucceed: true, + conflicts: true, + }), + term: 'merge request', + }); + const texts = collectTexts(tree); + expect(texts).toContain('Merge restrictions'); + expect(texts).toContain('Resolve the merge conflicts on this branch before merging.'); + expect(texts).toContain('2 approvals required'); + expect(texts).toContain('The pipeline must succeed before merging.'); + }); + + it('localizes known provider reasons and keeps the server message for `other`', () => { + // eslint-disable-next-line new-cap + const tree = MergeRestrictionsList({ + mergeState: mergeState({ + canMerge: false, + blockedReasons: [ + { code: 'failing_pipeline', message: 'Pipeline #123 failed' }, + { code: 'pending_pipeline', message: 'Pipeline #124 running' }, + { code: 'permission', message: '403 Forbidden' }, + { code: 'other', message: 'A merge is already running' }, + ], + }), + term: 'merge request', + }); + const texts = collectTexts(tree); + expect(texts).toContain('The pipeline is failing on the latest commit.'); + expect(texts).toContain('The pipeline is still running on the latest commit.'); + expect(texts).toContain("You don't have permission to merge this merge request."); + // The `other` code keeps the provider's own message verbatim. + expect(texts).toContain('A merge is already running'); + }); + + it('does not repeat a flag the reasons list already carries', () => { + // eslint-disable-next-line new-cap + const tree = MergeRestrictionsList({ + mergeState: mergeState({ + canMerge: false, + approvalsRequired: 3, + pipelineMustSucceed: true, + conflicts: true, + blockedReasons: [ + { code: 'conflicts', message: 'conflicts exist' }, + { code: 'failing_pipeline', message: 'Pipeline #123 failed' }, + ], + }), + term: 'merge request', + }); + const texts = collectTexts(tree); + const conflictRows = texts.filter( + text => text === 'Resolve the merge conflicts on this branch before merging.' + ); + expect(conflictRows).toHaveLength(1); + // The pipeline flag row is suppressed: the failing-pipeline reason says it. + expect(texts).not.toContain('The pipeline must succeed before merging.'); + expect(texts).toContain('3 approvals required'); + }); + + it('maps the draft reason onto the provider noun', () => { + // eslint-disable-next-line new-cap + const tree = MergeRestrictionsList({ + mergeState: mergeState({ + canMerge: false, + blockedReasons: [{ code: 'draft', message: 'Draft status' }], + }), + term: 'merge request', + }); + expect(collectTexts(tree)).toContain( + 'Mark the merge request as ready for review before merging.' + ); + }); + + it('renders nothing when no restriction applies', () => { + // eslint-disable-next-line new-cap + expect(MergeRestrictionsList({ mergeState: mergeState(), term: 'merge request' })).toBeNull(); + }); +}); + +describe('ProviderAutoMergeBody (s6)', () => { + it('explains the arm in the provider noun and repeats the restrictions', () => { + // eslint-disable-next-line new-cap + const tree = ProviderAutoMergeBody({ + mergeState: mergeState({ approvalsRequired: 2 }), + term: 'merge request', + }); + // The restrictions render as a mounted MergeRestrictionsList child. + const restrictions = requireComponent(tree, MergeRestrictionsList); + expect( + (restrictions.props as { mergeState?: ProviderPrMergeState }).mergeState?.approvalsRequired + ).toBe(2); + expect(collectTexts(tree)).toContain( + 'GitLab merges this merge request automatically once its pipeline succeeds.' + ); + }); + + it('renders just the explanation while the merge state has not loaded', () => { + // eslint-disable-next-line new-cap + const tree = ProviderAutoMergeBody({ mergeState: null, term: 'merge request' }); + expect(collectTexts(tree)).toEqual([ + 'GitLab merges this merge request automatically once its pipeline succeeds.', + ]); + }); +}); + +function conflictError(message: string): Error { + return Object.assign(new Error(message), { data: { code: 'CONFLICT' } }); +} + +describe('staleHeadRejectionMessage (s6)', () => { + it('returns the provider reason for a moved head', () => { + const message = + 'The merge request changed since it was loaded. Reload the merge request and try again.'; + expect(staleHeadRejectionMessage(conflictError(message))).toBe(message); + }); + + it('returns the reason for a target closed without merging', () => { + const message = 'The merge request was closed without merging.'; + expect(staleHeadRejectionMessage(conflictError(message))).toBe(message); + }); + + it('leaves other conflicts to the generic classification', () => { + expect(staleHeadRejectionMessage(conflictError('A merge is already running'))).toBeNull(); + }); + + it('ignores errors that are not conflicts', () => { + expect( + staleHeadRejectionMessage( + Object.assign(new Error('The merge request changed since it was loaded.'), { + data: { code: 'FORBIDDEN' }, + }) + ) + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx index 2de1156233..d9b1067fe1 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx @@ -14,19 +14,36 @@ // dismisses (cancel) or the mutation succeeds (auto-dismiss). import * as Haptics from 'expo-haptics'; -import { Alert, Keyboard, ScrollView, type TextInput, useWindowDimensions } from 'react-native'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Alert, + Keyboard, + ScrollView, + type TextInput, + useWindowDimensions, + View, +} from 'react-native'; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { type inferRouterInputs, type MobileRouter } from '@kilocode/trpc/mobile'; +import { + type ProviderPrMergeBlockedReason, + type ProviderPrMergeState, + type ProviderPrPlatform, + type ProviderReviewCapability, +} from '@kilocode/app-shared/provider-review'; -import { PrFormSheetHeader } from '@/components/pr-review/pr-form-sheet-chrome'; +import { PrFormSheetFooter, PrFormSheetHeader } from '@/components/pr-review/pr-form-sheet-chrome'; +import { PrReviewCapabilityBanner } from '@/components/pr-review/pr-review-capability-banner'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; import { type AllowedMergeMethod, type PrMergeMethod, type PrOverviewRepoSettings, } from '@/lib/pr-review/merge/merge-blocked-reasons'; import { + type EnableAutoMergeVars, + type MergeVars, useEnableAutoMergeMutation, useMergePullRequestMutation, } from '@/lib/pr-review/merge/use-pr-merge-mutations'; @@ -41,11 +58,16 @@ import { mergeMethodOptionsFor, } from '@/components/pr-review/merge/pr-merge-icons'; import { MergeSheetFormBody } from '@/components/pr-review/merge/pr-merge-sheet-parts'; +import { providerPrNounKey } from '@/components/pr-review/pr-review-provider-noun'; import { defaultCommitMessage, defaultCommitTitle, } from '@/lib/pr-review/merge/merge-commit-defaults'; import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; +import { formatNumber } from '@/lib/format'; +import { i18n } from '@/i18n'; +import { type ProviderPrRef, providerPrRefKey } from '@/lib/pr-review/provider-pr-ref'; +import { readTrpcErrorField } from '@/lib/trpc-error'; import { clearDraft, isMergeDraft, prMergeDraftKey, saveDraft } from '@/lib/persist/drafts'; import { useDraftFlushOnBackground } from '@/lib/persist/use-draft-flush'; import { useFencedDraftLoad } from '@/lib/persist/use-draft-load'; @@ -69,15 +91,67 @@ type PrMergeSheetProps = Readonly<{ mode: PrMergeSheetMode; sheetTitle: string; eyebrow: string; + /** + * The provider ref (s6). Present on the GitLab/Bitbucket surface: the merge + * posts through `providerReview.mergePullRequest` with the head fence, the + * method list comes from the provider (not the GitHub repo settings), and + * the draft key folds the ref identity. Absent on GitHub, which keeps the + * exact pre-s6 path. + */ + prRef?: ProviderPrRef; + /** + * The provider merge gate from `providerReview.getMergeState` (s2/s3). + * Rendered as the restrictions list and gates the submit; null on GitHub, + * whose gate derives from the overview DTO in the merge section. + */ + mergeState?: ProviderPrMergeState | null; + /** + * The auto-merge capability (provider arms). A `supported: false` answer + * (Bitbucket) renders the explicit capability banner instead of the form. + */ + autoMergeCapability?: ProviderReviewCapability; /** Called after a successful merge / auto-merge enable so the orchestrator can refetch. */ onRefetch: () => Promise; /** Called when the user cancels or after a successful submit. */ onDismiss: () => void; }>; -type RouterInputs = inferRouterInputs; -type MergePullRequestInput = RouterInputs['githubPrReview']['mergePullRequest']; -type AutoMergeInput = RouterInputs['githubPrReview']['enableAutoMerge']; +/** + * The provider's own noun in sentence form (s6). Defined in + * `pr-review-provider-noun.ts` (shared with the overview's provider merge + * arm) and re-exported here for the sheet's callers. + */ +export { providerPrNounKey } from '@/components/pr-review/pr-review-provider-noun'; + +/** + * The explicit stale-head rejection (s6): the server refuses a merge whose + * head moved (or whose target closed) with CONFLICT carrying the provider's + * own reason. There is nothing to retry against the old head, so the sheet + * keeps that reason inline and stays put — no redirect, no retry affordance. + * Returns the reason to show, or null when the error is not a stale-head. + */ +export function staleHeadRejectionMessage(error: unknown): string | null { + if (readTrpcErrorField(error, 'code') !== 'CONFLICT') { + return null; + } + const message = error instanceof Error ? error.message : ''; + return /changed since it was loaded|closed without merging/.test(message) ? message : null; +} + +/** + * The inline copy for a refused merge (s6f): the provider arm words the + * refusal after the connected provider (merge request vs pull request) + * through the existing term-parameterized key; GitHub keeps the exact + * pre-s6 copy. + */ +export function mergeForbiddenCopy( + platform: ProviderPrPlatform | undefined, + t: ReturnType['t'] +): string { + return platform + ? t('prReview.merge.providerBlocked.permission', { term: t(providerPrNounKey(platform)) }) + : t('prReview.merge.forbidden'); +} /** * Wraps an uncontrolled-input ref so every `.current` write (the parts file's @@ -113,22 +187,45 @@ export function PrMergeSheet(props: PrMergeSheetProps) { mode, sheetTitle, eyebrow, + prRef, + mergeState, + autoMergeCapability, onRefetch, onDismiss, } = props; const { t } = useTranslation(); - const methodOptions = useMemo(() => mergeMethodOptionsFor(repoSettings), [repoSettings]); + // Provider arms derive the method list from the platform, not the GitHub + // repo settings: GitLab offers merge + squash, Bitbucket Cloud only the + // merge commit. GitHub keeps the repo-settings list unchanged. + const providerMethodOptions = useMemo(() => { + if (!prRef) { + return null; + } + return mergeMethodOptionsFor({ + ...repoSettings, + allowMergeCommit: true, + allowSquashMerge: prRef.platform === 'gitlab', + allowRebaseMerge: false, + allowAutoMerge: prRef.platform === 'gitlab', + }); + }, [prRef, repoSettings]); + const methodOptions = useMemo( + () => providerMethodOptions ?? mergeMethodOptionsFor(repoSettings), + [providerMethodOptions, repoSettings] + ); const safeInitial: AllowedMergeMethod = useMemo( () => methodOptions.find(o => o.value === initialMethod)?.value ?? - defaultMergeMethodOptionFor(repoSettings), - [initialMethod, methodOptions, repoSettings] + (providerMethodOptions + ? (providerMethodOptions[0]?.value ?? defaultMergeMethodOptionFor(repoSettings)) + : defaultMergeMethodOptionFor(repoSettings)), + [initialMethod, methodOptions, providerMethodOptions, repoSettings] ); const [method, setMethod] = useState(safeInitial); - const showDeleteBranchToggle = !isCrossRepo; + const showDeleteBranchToggle = prRef ? true : !isCrossRepo; const [deleteBranch, setDeleteBranch] = useState(repoSettings.deleteBranchOnMerge); // iOS uncontrolled-input pattern: store text in a ref via onChangeText, @@ -145,7 +242,10 @@ export function PrMergeSheet(props: PrMergeSheetProps) { // read while the user id is unknown. The inputs render only once the draft // settles, seeded from the stored value or today's defaults. const { userId, isLoading: isIdentityLoading } = useCurrentUserId(); - const mergeDraftKey = prMergeDraftKey(owner, repoName, number); + const positionDraftKey = prMergeDraftKey(owner, repoName, number); + // Provider arms fold the collision-free ref identity into the key (identity + // rule 17); the GitHub bytes stay exactly as stored before this slice. + const mergeDraftKey = prRef ? `${positionDraftKey}@${providerPrRefKey(prRef)}` : positionDraftKey; const draft = useFencedDraftLoad<{ title: string; message: string }>({ userId, isIdentityLoading, @@ -190,13 +290,16 @@ export function PrMergeSheet(props: PrMergeSheetProps) { [owner, repoName, number] ); - const mergeMutation = useMergePullRequestMutation(ref); - const enableAutoMergeMutation = useEnableAutoMergeMutation(ref); + const mergeMutation = useMergePullRequestMutation(prRef ?? ref); + const enableAutoMergeMutation = useEnableAutoMergeMutation(prRef ?? ref); const isMutating = (mode === 'merge' && mergeMutation.isPending) || (mode === 'enable-auto-merge' && enableAutoMergeMutation.isPending); const lastError = mode === 'merge' ? mergeMutation.error : enableAutoMergeMutation.error; + // The provider platform as a stable primitive: the error effect words a + // refusal after it without depending on the `prRef` object identity. + const providerPlatform = prRef?.platform; useEffect(() => { if (lastError) { @@ -207,11 +310,20 @@ export function PrMergeSheet(props: PrMergeSheetProps) { setInlineErrorKind('non-retryable'); return; } + // A moved head is the explicit stale-head rejection (s6): the server + // answers CONFLICT with the provider's own reason, there is nothing to + // retry against the old head, and the sheet stays put showing it. + const staleHead = staleHeadRejectionMessage(lastError); + if (staleHead !== null) { + setInlineError(staleHead); + setInlineErrorKind('non-retryable'); + return; + } const classification = classifyPrReviewMutationError(lastError); if (classification.kind === 'bad-request' || classification.kind === 'forbidden') { setInlineError( classification.kind === 'forbidden' - ? t('prReview.merge.forbidden') + ? mergeForbiddenCopy(providerPlatform, t) : t('prReview.merge.cannotMerge') ); setInlineErrorKind('non-retryable'); @@ -225,7 +337,7 @@ export function PrMergeSheet(props: PrMergeSheetProps) { setInlineErrorKind('retryable'); } } - }, [lastError, t]); + }, [lastError, t, providerPlatform]); useEffect(() => { const sub = Keyboard.addListener('keyboardDidShow', () => { @@ -242,7 +354,29 @@ export function PrMergeSheet(props: PrMergeSheetProps) { setMethod(next); } - function buildMergeInput(): MergePullRequestInput { + function buildMergeInput(): MergeVars { + if (prRef) { + // The provider merge carries the head fence (the server refuses a moved + // head before any merge call); GitLab folds the method into `squash` + // and takes a commit title, Bitbucket only the message — and the form + // hides the title input on that arm (s6f), so no typed value is ever + // dropped. The term rides the fingerprint so a retried intent re-merges + // the same revision. + const commitMessage = messageRef.current.trim(); + return { + expectedHeadSha: headSha, + ...(prRef.platform === 'gitlab' + ? { + squash: method === 'squash', + ...(titleRef.current.trim().length > 0 + ? { commitTitle: titleRef.current.trim() } + : {}), + } + : {}), + deleteBranch: showDeleteBranchToggle ? deleteBranch : false, + ...(commitMessage.length > 0 ? { commitMessage } : {}), + }; + } return { owner, repo: repoName, @@ -255,7 +389,13 @@ export function PrMergeSheet(props: PrMergeSheetProps) { }; } - function buildAutoMergeInput(): AutoMergeInput { + function buildAutoMergeInput(): EnableAutoMergeVars { + if (prRef) { + // GitLab arms merge-when-pipeline-succeeds fenced on the head; the + // method rides the server-side squash handling. Bitbucket never gets + // here: the capability banner replaces the form. + return { expectedHeadSha: headSha }; + } const autoMethod: 'MERGE' | 'SQUASH' | 'REBASE' = (() => { if (method === 'merge') { return 'MERGE'; @@ -328,8 +468,16 @@ export function PrMergeSheet(props: PrMergeSheetProps) { void performSubmit(); }; + // Provider arms word the nouns after the connected provider (merge + // request vs pull request); GitHub keeps its exact pre-s6 copy. if (mode === 'merge') { - Alert.alert(t('prReview.merge.confirmTitle'), t('prReview.merge.confirmMessage'), [ + const [confirmTitle, confirmMessage] = prRef + ? [ + t('prReview.merge.confirmTitleTerm', { term: t(providerPrNounKey(prRef.platform)) }), + t('prReview.merge.confirmMessage'), + ] + : [t('prReview.merge.confirmTitle'), t('prReview.merge.confirmMessage')]; + Alert.alert(confirmTitle, confirmMessage, [ { text: t('common.cancel'), style: 'cancel' }, { text: t('prReview.merge.merge'), style: 'destructive', onPress: submit }, ]); @@ -337,7 +485,11 @@ export function PrMergeSheet(props: PrMergeSheetProps) { } Alert.alert( t('prReview.merge.enableAutoMergeConfirmTitle'), - t('prReview.merge.enableAutoMergeConfirmMessage'), + prRef + ? t('prReview.merge.enableAutoMergeConfirmMessageTerm', { + term: t(providerPrNounKey(prRef.platform)), + }) + : t('prReview.merge.enableAutoMergeConfirmMessage'), [ { text: t('common.cancel'), style: 'cancel' }, { text: t('prReview.merge.enableAutoMerge'), style: 'destructive', onPress: submit }, @@ -347,6 +499,12 @@ export function PrMergeSheet(props: PrMergeSheetProps) { const submitLabel = mode === 'merge' ? t('prReview.merge.merge') : t('prReview.merge.enableAutoMerge'); + // Provider arms (s6): the provider's own noun for the confirm copy, and the + // two auto-merge shapes — GitLab arms through the seam, Bitbucket Cloud has + // no auto-merge API and opens onto the capability banner instead. + const providerTerm = prRef ? t(providerPrNounKey(prRef.platform)) : ''; + const providerAutoMerge = Boolean(prRef) && mode === 'enable-auto-merge'; + const autoMergeUnsupported = providerAutoMerge && autoMergeCapability?.supported === false; // A repository can (rarely) have every merge method disabled. GitHub would // reject any submission, so surface it explicitly and block the action // rather than sending a method the repo does not allow. @@ -361,7 +519,116 @@ export function PrMergeSheet(props: PrMergeSheetProps) { onDismiss(); } + // The body a settled draft renders: the arm the provider state selects — + // the Bitbucket auto-merge capability banner, the GitLab auto-merge body, + // a blocked merge state's restrictions, or the form. The cancel-only + // arms share the ghost footer button. + function cancelOnlyFooter() { + return ( + + + + ); + } + + const settledBody = ((): ReactNode => { + if (autoMergeUnsupported) { + // A Bitbucket auto-merge opens onto the capability banner: the provider + // has no API to arm, so there is nothing to submit or retry. + return ( + <> + + + + {cancelOnlyFooter()} + + ); + } + if (providerAutoMerge) { + return ( + <> + + + + + + + ); + } + if (mergeState && !mergeState.canMerge) { + // A blocked merge state replaces the form with the restrictions list + // (nothing to submit). + return ( + <> + + + + {cancelOnlyFooter()} + + ); + } + return ( + <> + {mergeState ? ( + + + + ) : null} + + + ); + })(); + // PickerSheet invariant: [header, ScrollView]; footer is trailing content. + // Provider arms (s6): the s2/s3 merge state renders as the restrictions + // list; a blocked state replaces the form with that list (nothing to + // submit), and a Bitbucket auto-merge opens onto the capability banner. return ( <> @@ -376,30 +643,112 @@ export function PrMergeSheet(props: PrMergeSheetProps) { setScrollViewportHeight(event.nativeEvent.layout.height); }} > - {draft.settled ? ( - - ) : null} + {draft.settled ? settledBody : null} ); } + +/** The localized copy for one provider blocked reason; `other` keeps the server's message. */ +function providerBlockedReasonText( + reason: ProviderPrMergeBlockedReason, + term: string, + t: ReturnType['t'] +): string { + // Literal keys, never a template: the catalog check scans the source for + // the keys a lookup passes on, and a computed key is invisible to it. + const KEY_BY_CODE = { + conflicts: 'prReview.merge.blocked.conflictsDetail', + required_approvals: 'prReview.merge.blocked.requiredReviewsDetail', + failing_pipeline: 'prReview.merge.providerBlocked.failingPipeline', + pending_pipeline: 'prReview.merge.providerBlocked.pendingPipeline', + draft: 'prReview.merge.providerBlocked.draft', + permission: 'prReview.merge.providerBlocked.permission', + other: null, + } satisfies Record; + const key = KEY_BY_CODE[reason.code]; + if (key === null) { + return reason.message; + } + return t(key, { term }); +} + +/** + * The s2/s3 merge state as an explicit restrictions list (s6): the branch + * policy flags first, then the provider's concrete blocked reasons. Rows the + * reasons list already carries are not repeated from the flags. + */ +export function MergeRestrictionsList({ + mergeState, + term, +}: Readonly<{ mergeState: ProviderPrMergeState; term: string }>) { + const { t } = useTranslation(); + const hasConflictReason = mergeState.blockedReasons.some(reason => reason.code === 'conflicts'); + const hasApprovalsReason = mergeState.blockedReasons.some( + reason => reason.code === 'required_approvals' + ); + const hasPipelineReason = mergeState.blockedReasons.some( + reason => reason.code === 'failing_pipeline' || reason.code === 'pending_pipeline' + ); + const rows: { id: string; text: string }[] = []; + if (mergeState.conflicts && !hasConflictReason) { + rows.push({ id: 'conflicts', text: t('prReview.merge.blocked.conflictsDetail') }); + } + if (mergeState.approvalsRequired > 0 && !hasApprovalsReason) { + rows.push({ + id: 'approvals', + text: t('prReview.merge.restrictions.approvalsRequired', { + count: mergeState.approvalsRequired, + displayCount: formatNumber(mergeState.approvalsRequired, i18n.language), + }), + }); + } + if (mergeState.pipelineMustSucceed && !hasPipelineReason) { + rows.push({ + id: 'pipeline', + text: t('prReview.merge.restrictions.pipelineMustSucceed'), + }); + } + for (const reason of mergeState.blockedReasons) { + rows.push({ + id: `blocked:${reason.code}:${reason.message}`, + text: providerBlockedReasonText(reason, term, t), + }); + } + if (rows.length === 0) { + return null; + } + return ( + + + {t('prReview.merge.restrictions.title')} + + {rows.map(row => ( + + {'• '} + {row.text} + + ))} + + ); +} + +/** + * The GitLab auto-merge body (s6): the merge state's restrictions plus one + * plain explanation. No form fields exist on this arm — arming rides only + * the head fence. + */ +export function ProviderAutoMergeBody({ + mergeState, + term, +}: Readonly<{ mergeState: ProviderPrMergeState | null | undefined; term: string }>) { + const { t } = useTranslation(); + return ( + + {mergeState ? : null} + + {t('prReview.merge.enableAutoMergeDescriptionTerm', { term })} + + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-capability-banner.mounted.test.tsx b/apps/mobile/src/components/pr-review/pr-review-capability-banner.mounted.test.tsx new file mode 100644 index 0000000000..456b4794d1 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-capability-banner.mounted.test.tsx @@ -0,0 +1,82 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom) */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import '@/i18n'; +import { type ProviderReviewCapability } from '@kilocode/app-shared/provider-review'; +import type * as ReactI18next from 'react-i18next'; +import { PrReviewCapabilityBanner } from './pr-review-capability-banner'; + +vi.mock('react-i18next', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => { + const i18n = actual.getI18n(); + return { t: i18n.t.bind(i18n), i18n }; + }, + }; +}); + +vi.mock('react-native', () => ({ View: 'View' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); + +const UNSUPPORTED: ProviderReviewCapability = { + supported: false, + reason: 'Bitbucket Cloud does not expose auto-merge in its API', +}; +const SUPPORTED: ProviderReviewCapability = { supported: true, reason: '' }; + +function renderBanner( + capability: ProviderReviewCapability | undefined +): TestRenderer.ReactTestRenderer { + let renderer: TestRenderer.ReactTestRenderer | null = null; + act(() => { + renderer = TestRenderer.create(createElement(PrReviewCapabilityBanner, { capability })); + }); + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- the act() callback runs synchronously; this narrows the definite assignment + if (!renderer) { + throw new Error('Failed to create test renderer'); + } + return renderer; +} + +function textsOf(renderer: TestRenderer.ReactTestRenderer): string[] { + return renderer.root + .findAll(node => (node.type as string) === 'Text') + .flatMap(node => [node.props.children as string]) + .flat() + .filter((child): child is string => typeof child === 'string'); +} + +describe('PrReviewCapabilityBanner', () => { + it('renders the localized title and the provider reason for a supported:false capability', () => { + const renderer = renderBanner(UNSUPPORTED); + const texts = textsOf(renderer); + expect(texts).toContain('Not available on this provider'); + expect(texts).toContain(UNSUPPORTED.reason); + renderer.unmount(); + }); + + it('announces title and reason together for accessibility', () => { + const renderer = renderBanner(UNSUPPORTED); + const view = renderer.root.find(node => (node.type as string) === 'View'); + expect(view.props.accessibilityLabel).toBe( + `Not available on this provider: ${UNSUPPORTED.reason}` + ); + renderer.unmount(); + }); + + it('renders nothing for a supported capability — the affordance itself shows', () => { + const renderer = renderBanner(SUPPORTED); + expect(renderer.toJSON()).toBeNull(); + renderer.unmount(); + }); + + it('renders nothing while the capability is not loaded (undefined)', () => { + const renderer = renderBanner(undefined); + expect(renderer.toJSON()).toBeNull(); + renderer.unmount(); + }); +}); diff --git a/apps/mobile/src/components/pr-review/pr-review-capability-banner.tsx b/apps/mobile/src/components/pr-review/pr-review-capability-banner.tsx new file mode 100644 index 0000000000..1dcda5690c --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-capability-banner.tsx @@ -0,0 +1,37 @@ +// The explicit capability explanation (s6). A provider that cannot do +// something answers `{ supported: false, reason }`; this banner renders that +// answer as a visible, localized explanation — never a silent absence and +// never a generic failure. Any review surface holding a capability object +// (the merge sheet's Bitbucket auto-merge arm today, the discussion +// limitations after it) renders it through here so the wording stays one. + +import { useTranslation } from 'react-i18next'; +import { View } from 'react-native'; + +import { type ProviderReviewCapability } from '@kilocode/app-shared/provider-review'; + +import { Text } from '@/components/ui/text'; + +export function PrReviewCapabilityBanner({ + capability, +}: Readonly<{ capability: ProviderReviewCapability | undefined }>) { + const { t } = useTranslation(); + // A supported (or not-yet-loaded) capability has nothing to explain: the + // surface renders the affordance itself, so the banner draws nothing. + if (capability === undefined || capability.supported) { + return null; + } + return ( + + + {t('prReview.capabilities.banner.title')} + + {capability.reason} + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx index d03bfb59b1..2eeae002e6 100644 --- a/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { type ReactNode, useEffect, useRef } from 'react'; +import { type ReactNode, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Alert } from 'react-native'; @@ -13,6 +13,14 @@ import { InvalidRouteState } from '@/components/invalid-route-state'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { parseComposerParams } from '@/lib/pr-review/comment-composer-params'; import { usePendingReview } from '@/lib/pr-review/pending-review-provider'; +import { buildPrOverviewQueryOptions } from '@/lib/pr-review/provider-pr-queries'; +import { + isProviderScopeReady, + parseProviderPrRoute, + providerPrRefLabel, + providerPrTriple, + useProviderPrScope, +} from '@/lib/pr-review/provider-pr-ref'; import { useTRPC } from '@/lib/trpc'; type Params = { @@ -24,30 +32,76 @@ type Params = { line: string; startLine?: string; pendingId?: string; + // Provider route shape (`[platform]/[...identity]/comment-composer`). + platform?: string; + identity?: string[] | string; + instance?: string; }; +/** + * Comment-composer formSheet, mounted by BOTH routes: the GitHub + * `[owner]/[repo]/[number]/comment-composer` route and the provider + * `[platform]/[...identity]/comment-composer` route (s6). The route decides + * the ref; the provider layout publishes the scope the queries run under. + */ export function PrReviewCommentComposerScreen() { const router = useRouter(); const colors = useThemeColors(); const { t } = useTranslation(); const params = useLocalSearchParams(); - const parsed = parseComposerParams(params); const pending = usePendingReview(); + // The provider route carries the identity segments; the GitHub route the + // plain triple. Exactly one parses — the provider layout redirects a + // hand-built `/pr-review/github/...` link to the GitHub route. + const providerRef = useMemo( + () => + parseProviderPrRoute({ + platform: params.platform, + identity: params.identity, + instance: params.instance, + }), + [params.platform, params.identity, params.instance] + ); + const providerTriple = providerRef ? providerPrTriple(providerRef) : null; + const parsed = useMemo( + () => + parseComposerParams( + providerTriple + ? { + ...params, + owner: providerTriple.owner, + repo: providerTriple.repo, + number: String(providerTriple.number), + } + : params + ), + // `providerTriple` is derived from `providerRef`; the segments it reads + // are the same memo inputs. + [params, providerTriple] + ); + const pendingId = parsed?.pendingId; const isEdit = pendingId !== undefined; const pendingItem = isEdit ? pending.items.find(item => item.id === pendingId) : undefined; const title = isEdit ? t('prReview.composer.editTitle') : t('prReview.composer.addTitle'); - const eyebrow = parsed ? `${parsed.owner}/${parsed.repo}#${parsed.number}` : ''; + const githubEyebrow = parsed ? `${parsed.owner}/${parsed.repo}#${parsed.number}` : ''; + const eyebrow = providerRef ? providerPrRefLabel(providerRef) : githubEyebrow; + + // The scope the overview runs under: the layout publishes the provider + // scope in context; the GitHub route falls back to the parsed triple, so + // the GitHub query key is byte-identical to the pre-s6 one. + const scope = useProviderPrScope(parsed ?? { owner: '', repo: '', number: 0 }); // Edit mode is local-only: do not fire getPullRequest and do not gate on it. + // The readiness gate rides the same predicate the provider reads use, so a + // Bitbucket scope without its organization waits instead of querying. const trpc = useTRPC(); - const pr = useQuery( - trpc.githubPrReview.getPullRequest.queryOptions( - { owner: parsed?.owner ?? '', repo: parsed?.repo ?? '', number: parsed?.number ?? 0 }, - { enabled: parsed !== null && !isEdit } - ) - ); + const overviewOptions = useMemo(() => buildPrOverviewQueryOptions(trpc, scope), [trpc, scope]); + const pr = useQuery({ + ...overviewOptions, + enabled: parsed !== null && !isEdit && isProviderScopeReady(scope), + }); // Missing pending item: alert above the formSheet and back out once. const missingAlertedRef = useRef(false); @@ -82,6 +136,7 @@ export function PrReviewCommentComposerScreen() { owner={parsed.owner} repo={parsed.repo} number={parsed.number} + prRef={providerRef && providerRef.platform !== 'github' ? providerRef : undefined} mode={{ kind: 'edit', pendingItemId: pendingItem.id }} path={parsed.path} side={parsed.side} @@ -103,6 +158,7 @@ export function PrReviewCommentComposerScreen() { owner={parsed.owner} repo={parsed.repo} number={parsed.number} + prRef={providerRef && providerRef.platform !== 'github' ? providerRef : undefined} mode={{ kind: 'create', headSha: pr.data.headSha }} path={parsed.path} side={parsed.side} diff --git a/apps/mobile/src/components/pr-review/pr-review-comment-composer.test.tsx b/apps/mobile/src/components/pr-review/pr-review-comment-composer.test.tsx index 1a1820b71d..ec50ea7e99 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 @@ -14,6 +14,7 @@ import '@/i18n'; import type * as ReactI18next from 'react-i18next'; import { PrReviewCommentComposer } from './pr-review-comment-composer'; import { clearDraft } from '@/lib/persist/drafts'; +import { providerPrRefKey } from '@/lib/pr-review/provider-pr-ref'; vi.mock('react-i18next', async importOriginal => { const actual = await importOriginal(); @@ -153,6 +154,18 @@ 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, @@ -287,3 +300,55 @@ describe('PrReviewCommentComposer draft clear rules', () => { expect(clearDraft).not.toHaveBeenCalled(); }); }); + +describe('PrReviewCommentComposer provider arm (s6)', () => { + // A GitLab MR with the same owner/repo/number triple as the GitHub + // fixtures: the folded draft key and the body-only post must differ from + // the GitHub arm in both bytes. + const gitlabRef = { platform: 'gitlab' as const, projectPath: 'octocat/hello', mrIid: 1 }; + const providerProps = { ...baseProps, prRef: gitlabRef }; + + function mountProviderComposer(): React.ReactElement { + // eslint-disable-next-line new-cap + return PrReviewCommentComposer(providerProps); + } + + beforeEach(() => { + createCommentMocks.mutateAsync.mockReset(); + createCommentMocks.isPending = false; + createCommentMocks.error = null; + vi.clearAllMocks(); + }); + + it('posts body-only through the provider arm, with the location anchored in the text', async () => { + createCommentMocks.mutateAsync.mockResolvedValueOnce({}); + const element = mountProviderComposer(); + typeBody(element, 'hello'); + footerProp(element, 'onCommentNow')?.(); + await flushMicrotasks(); + + expect(createCommentMocks.mutateAsync).toHaveBeenCalledWith({ + body: 'src/a.ts:L10\n\nhello', + }); + }); + + it('keeps the provider comment out of the pending queue path (comment now is direct)', () => { + createCommentMocks.mutateAsync.mockResolvedValueOnce({}); + const element = mountProviderComposer(); + typeBody(element, 'hello'); + footerProp(element, 'onAddToReview')?.(); + + // Add-to-review stays provider-agnostic: the queue is local and the + // submit sheet folds it into the review body later. + expect(createCommentMocks.mutateAsync).not.toHaveBeenCalled(); + }); + + it('folds the provider ref identity into the durable comment draft key', () => { + const element = mountProviderComposer(); + typeBody(element, 'hello'); + footerProp(element, 'onAddToReview')?.(); + + const expected = `pr-comment:key@${providerPrRefKey(gitlabRef)}`; + expect(clearDraft).toHaveBeenCalledWith('u1', expected); + }); +}); diff --git a/apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx b/apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx index 9b37ca9e58..31bac6df50 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,7 +35,11 @@ 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 { useCreateReviewCommentMutation } from '@/lib/pr-review/use-pr-review-mutations'; +import { + formatPendingCommentBody, + useCreateReviewCommentMutation, +} from '@/lib/pr-review/use-pr-review-mutations'; +import { type ProviderPrRef, providerPrRefKey } from '@/lib/pr-review/provider-pr-ref'; type CommentComposerMode = | { kind: 'create'; headSha: string } @@ -45,6 +49,14 @@ type PrReviewCommentComposerProps = Readonly<{ owner: string; repo: string; number: number; + /** + * The provider ref (s6). Present on the GitLab/Bitbucket surface: the + * comment posts through `providerReview.addComment` with the 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. + */ + prRef?: ProviderPrRef; mode: CommentComposerMode; path: string; side: 'LEFT' | 'RIGHT'; @@ -62,6 +74,7 @@ export function PrReviewCommentComposer(props: PrReviewCommentComposerProps) { owner, repo, number, + prRef, mode, path, side, @@ -74,13 +87,18 @@ export function PrReviewCommentComposer(props: PrReviewCommentComposerProps) { } = props; const pending = usePendingReview(); const { t } = useTranslation(); - const createComment = useCreateReviewCommentMutation({ owner, repo, number }); + const createComment = useCreateReviewCommentMutation(prRef ?? { owner, repo, number }); const isEdit = mode.kind === 'edit'; // Durable comment draft (create mode only). Edit mode edits an already-queued // item, durable through the pending-review provider, so no draft there. const { userId, isLoading: isIdentityLoading } = useCurrentUserId(); - const commentDraftKey = prCommentDraftKey(owner, repo, number, path, side, line, startLine); + const positionDraftKey = prCommentDraftKey(owner, repo, number, path, side, line, startLine); + // Provider arms fold the collision-free ref identity into the key (identity + // rule 17); the GitHub bytes stay exactly as stored before this slice. + const commentDraftKey = prRef + ? `${positionDraftKey}@${providerPrRefKey(prRef)}` + : positionDraftKey; const draftUserId = isEdit ? undefined : userId; const draft = useFencedDraftLoad({ userId: draftUserId, @@ -199,17 +217,23 @@ export function PrReviewCommentComposer(props: PrReviewCommentComposerProps) { return; } try { - await createComment.mutateAsync({ - owner, - repo, - number, - body, - path, - line, - side, - ...(startLine !== undefined ? { startLine, startSide: side } : {}), - commitSha: mode.headSha, - }); + // Provider arms post body-only: the inline position rides in the + // text, because the provider APIs have no comment position. + await createComment.mutateAsync( + prRef + ? { body: formatPendingCommentBody({ path, line, startLine, body }) } + : { + owner, + repo, + number, + body, + path, + line, + side, + ...(startLine !== undefined ? { startLine, startSide: side } : {}), + commitSha: mode.headSha, + } + ); if (draftUserId) { void clearDraft(draftUserId, commentDraftKey); } diff --git a/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx index e8d5dd83dd..0f9c5bdb08 100644 --- a/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx @@ -8,7 +8,7 @@ import { ActivityIndicator, Alert, Pressable, TextInput, View } from 'react-nati import { EmptyState } from '@/components/empty-state'; import { PrReviewInboxList } from '@/components/pr-review/pr-review-inbox-list'; -import { selectRecentPrRowState } from '@/components/pr-review/recent-pr-row-state'; +import { selectRecentPrRowState } from '@/lib/pr-review/recent-pr-row-state'; import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; diff --git a/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx index b11bfebafd..3352b86fcc 100644 --- a/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { type ReactNode } from 'react'; +import { type ReactNode, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator } from 'react-native'; @@ -8,10 +8,23 @@ import { CenteredState } from '@/components/centered-state'; import { PrFormSheetHeader } from '@/components/pr-review/pr-form-sheet-chrome'; import { QueryError } from '@/components/query-error'; -import { PrMergeSheet } from '@/components/pr-review/merge/pr-merge-sheet'; +import { PrMergeSheet, providerPrNounKey } from '@/components/pr-review/merge/pr-merge-sheet'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type PrMergeMethod } from '@/lib/pr-review/merge/merge-blocked-reasons'; import { parseParam } from '@/lib/route-params'; +import { + buildPrMergeStateQueryOptions, + buildPrOverviewQueryOptions, + providerCapabilitiesIdentity, + selectProviderCapabilitiesData, +} from '@/lib/pr-review/provider-pr-queries'; +import { + isProviderScopeReady, + parseProviderPrRoute, + providerPrRefLabel, + providerPrTriple, + useProviderPrScope, +} from '@/lib/pr-review/provider-pr-ref'; import { useTRPC } from '@/lib/trpc'; type Params = { @@ -20,6 +33,10 @@ type Params = { number: string; mode?: string; method?: string; + // Provider route shape (`[platform]/[...identity]/merge`). + platform?: string; + identity?: string[] | string; + instance?: string; }; const MERGE_METHODS = new Set(['merge', 'squash', 'rebase']); @@ -27,39 +44,112 @@ const MERGE_METHODS = new Set(['merge', 'squash', 'rebase']); /** * Merge formSheet route. Reads the PR + mode/method from params, fetches the * overview so the sheet has the repo settings + head SHA fence, and mounts the - * S8 merge sheet. Rendered inside the `[number]` layout's formSheet stack. + * merge sheet. Rendered inside BOTH the GitHub `[number]` layout and the + * provider `[...identity]` layout (s6): on provider arms the s2/s3 merge + * state rides along as the confirmation sheet's restrictions list, and the + * wording follows the connected provider (merge request vs pull request). */ export function PrReviewMergeScreen() { const router = useRouter(); const colors = useThemeColors(); const { t } = useTranslation(); const params = useLocalSearchParams(); - const owner = parseParam(params.owner) ?? ''; - const repo = parseParam(params.repo) ?? ''; - const rawNumber = parseParam(params.number) ?? ''; + + // The provider route carries the identity segments; the GitHub route the + // plain triple. Exactly one parses — the provider layout redirects a + // hand-built `/pr-review/github/...` link to the GitHub route. + const providerRef = useMemo( + () => + parseProviderPrRoute({ + platform: params.platform, + identity: params.identity, + instance: params.instance, + }), + [params.platform, params.identity, params.instance] + ); + const providerTriple = providerRef ? providerPrTriple(providerRef) : null; + const owner = providerTriple ? providerTriple.owner : (parseParam(params.owner) ?? ''); + const repo = providerTriple ? providerTriple.repo : (parseParam(params.repo) ?? ''); + const rawNumber = providerTriple + ? String(providerTriple.number) + : (parseParam(params.number) ?? ''); const number = Number.parseInt(rawNumber, 10); + const mode = params.mode === 'enable-auto-merge' ? 'enable-auto-merge' : 'merge'; const method: PrMergeMethod = MERGE_METHODS.has(params.method as PrMergeMethod) ? (params.method as PrMergeMethod) : 'merge'; - const sheetTitle = - mode === 'enable-auto-merge' - ? t('prReview.merge.enableAutoMerge') - : t('prReview.merge.mergePullRequest'); - const eyebrow = `${owner}/${repo}#${rawNumber}`; + const sheetTitle = (() => { + if (mode === 'enable-auto-merge') { + return t('prReview.merge.enableAutoMerge'); + } + if (providerRef && providerRef.platform !== 'github') { + return t('prReview.merge.mergeTermTitle', { + term: t(providerPrNounKey(providerRef.platform)), + }); + } + return t('prReview.merge.mergePullRequest'); + })(); + const eyebrow = providerRef ? providerPrRefLabel(providerRef) : `${owner}/${repo}#${rawNumber}`; const dismiss = () => { router.back(); }; + // The scope the reads run under: the layout publishes the provider scope in + // context; the GitHub route falls back to the parsed triple, so the GitHub + // query key is byte-identical to the pre-s6 one. + const scope = useProviderPrScope( + owner && repo && Number.isInteger(number) && number > 0 + ? { owner, repo, number } + : { owner: '', repo: '', number: 0 } + ); + const trpc = useTRPC(); - const pr = useQuery( - trpc.githubPrReview.getPullRequest.queryOptions( - { owner, repo, number }, - { enabled: Boolean(owner) && Boolean(repo) && Number.isInteger(number) && number > 0 } - ) + const overviewOptions = useMemo(() => buildPrOverviewQueryOptions(trpc, scope), [trpc, scope]); + const paramsValid = Boolean(owner) && Boolean(repo) && Number.isInteger(number) && number > 0; + const pr = useQuery({ + ...overviewOptions, + enabled: paramsValid && isProviderScopeReady(scope), + }); + + // The auto-merge capability, provider auto-merge arms only: a + // `supported: false` answer (Bitbucket) renders the capability banner. + // The GitHub arm keeps its query disabled — it never touches the + // `providerReview` namespace over the network. + const needsAutoMergeCapability = + providerRef !== null && providerRef.platform !== 'github' && mode === 'enable-auto-merge'; + // The options call sits directly in the body and the result is bound + // before `useQuery`: wrapped in a helper/useMemo closure the linter's + // inference loses the option type, and passed inline the type-checker's + // inference collapses every `.data` read. The data itself is read back + // through the seam's typed selector. + const capabilitiesOptions = trpc.providerReview.getCapabilities.queryOptions( + providerCapabilitiesIdentity(scope), + { + enabled: + needsAutoMergeCapability && scope.ref.platform !== 'github' && isProviderScopeReady(scope), + } ); + const capabilitiesQuery = useQuery(capabilitiesOptions); + const capabilitiesData = selectProviderCapabilitiesData(capabilitiesQuery.data); - if (pr.data) { + // The s2/s3 merge gate, provider arms only (GitHub's gate derives from the + // overview DTO in the merge section — the GitHub arm's query registers + // disabled and never touches the `providerReview` namespace). + const mergeStateOptions = useMemo( + () => buildPrMergeStateQueryOptions(trpc, scope), + [trpc, scope] + ); + const mergeStateQuery = useQuery(mergeStateOptions); + + // The sheet mounts only once its reads settle, so its content never shifts: + // loading → content happens in the screen body, not inside the sheet. + const isProviderArm = scope.ref.platform !== 'github'; + const mergeStateSettled = + !isProviderArm || (!mergeStateQuery.isLoading && !mergeStateQuery.isPending); + const capabilitiesSettled = !needsAutoMergeCapability || !capabilitiesQuery.isPending; + + if (pr.data && mergeStateSettled && capabilitiesSettled) { return ( { await pr.refetch(); }} @@ -85,20 +180,23 @@ export function PrReviewMergeScreen() { ); } - const body: ReactNode = pr.isLoading ? ( - - - - ) : ( - { - void pr.refetch(); - }} - isRetrying={pr.isFetching} - /> - ); + const body: ReactNode = + pr.isLoading || + (isProviderArm && mergeStateQuery.isLoading) || + (needsAutoMergeCapability && capabilitiesQuery.isLoading) ? ( + + + + ) : ( + { + void pr.refetch(); + }} + isRetrying={pr.isFetching} + /> + ); return ( <> diff --git a/apps/mobile/src/components/pr-review/pr-review-overview.tsx b/apps/mobile/src/components/pr-review/pr-review-overview.tsx index 37f3c5f821..d17a1b8e01 100644 --- a/apps/mobile/src/components/pr-review/pr-review-overview.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-overview.tsx @@ -14,6 +14,7 @@ import { MarkdownText } from '@/components/agents/markdown-text'; import { PrOverviewMeta } from '@/components/pr-review/pr-review-meta-parts'; import { PrReviewChecksSection } from '@/components/pr-review/pr-review-checks-section'; import { PrMergeSection } from '@/components/pr-review/merge/pr-merge-section'; +import { PrMergeSectionProvider } from '@/components/pr-review/merge/pr-merge-section-provider'; import { describePrState, formatPrCounts, @@ -286,7 +287,11 @@ export function PrReviewOverview({ }} isRefetching={pr.isFetching} /> - ) : null} + ) : ( + // The provider merge arm (s6): the merge affordance and the + // capability-gated auto-merge row, pushing the ref's own sheet route. + + )} {t('prReview.headLine', { diff --git a/apps/mobile/src/components/pr-review/pr-review-provider-noun.ts b/apps/mobile/src/components/pr-review/pr-review-provider-noun.ts new file mode 100644 index 0000000000..4892bd0182 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-provider-noun.ts @@ -0,0 +1,14 @@ +// The provider's own noun in sentence form (s6). The `prReview.terms.*` +// labels are capitalized for chips and headers, so a mid-sentence +// `{{term}}` interpolation rides the lowercase `common.*` nouns instead — +// "Merge Merge request?" never renders. Standalone (no React imports) so +// the merge sheet, the merge screen and the overview's provider merge arm +// share the one mapping without pulling each other's bundles together. + +import { type ProviderPrPlatform } from '@kilocode/app-shared/provider-review'; + +export function providerPrNounKey( + platform: ProviderPrPlatform +): 'common.mergeRequest' | 'common.pullRequest' { + return platform === 'gitlab' ? 'common.mergeRequest' : 'common.pullRequest'; +} diff --git a/apps/mobile/src/components/pr-review/pr-review-provider-sheet-href.ts b/apps/mobile/src/components/pr-review/pr-review-provider-sheet-href.ts new file mode 100644 index 0000000000..a8e19d4dab --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-provider-sheet-href.ts @@ -0,0 +1,33 @@ +// The write-sheet routes inside the provider layout (s6). The comment +// composer, the review-submit sheet and the merge sheet are children of the +// PR's own route on every provider: the provider scope and the +// `PendingReviewProvider` queue are published by the provider layout, so a +// provider sheet must be reached through the provider route — pushing the +// GitHub sibling would leave that scope and write to the wrong provider. The +// GitHub route keeps its own literal paths untouched. +import { type Href } from 'expo-router'; + +import { type ProviderPrRef, providerPrRouteSegments } from '@/lib/pr-review/provider-pr-ref'; + +export type ProviderPrSheetRoute = 'comment-composer' | 'review-submit' | 'merge'; + +/** + * The href for one sheet under the ref's own route. A GitLab `instanceHint` + * rides as the `instance` query param — the same param the provider layout + * reads for the base route — so the sheet stays on the instance the reader + * opened. Extra params (composer position, merge mode) ride as query params. + */ +export function providerPrSheetHref( + ref: ProviderPrRef, + sheet: ProviderPrSheetRoute, + params: Record = {} +): Href { + const { platform, identity } = providerPrRouteSegments(ref); + const encoded = identity.map(segment => encodeURIComponent(segment)).join('/'); + const instance = + ref.platform === 'gitlab' && ref.instanceHint ? { instance: ref.instanceHint } : {}; + return { + pathname: `/(app)/pr-review/${platform}/${encoded}/${sheet}`, + params: { ...instance, ...params }, + }; +} diff --git a/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx index 49c43db174..f07372e61b 100644 --- a/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { type ReactNode } from 'react'; +import { type ReactNode, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator } from 'react-native'; @@ -11,38 +11,108 @@ import { PrReviewSubmit } from '@/components/pr-review/pr-review-submit'; import { QueryError } from '@/components/query-error'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { parseParam } from '@/lib/route-params'; +import { + buildPrOverviewQueryOptions, + providerCapabilitiesIdentity, + selectProviderCapabilitiesData, +} from '@/lib/pr-review/provider-pr-queries'; +import { + isProviderScopeReady, + parseProviderPrRoute, + providerPrRefLabel, + providerPrTriple, + useProviderPrScope, +} from '@/lib/pr-review/provider-pr-ref'; import { useTRPC } from '@/lib/trpc'; type Params = { owner: string; repo: string; number: string; + // Provider route shape (`[platform]/[...identity]/review-submit`). + platform?: string; + identity?: string[] | string; + instance?: string; }; +/** + * Review-submit formSheet, mounted by BOTH routes: the GitHub + * `[owner]/[repo]/[number]/review-submit` route and the provider + * `[platform]/[...identity]/review-submit` route (s6). The review events the + * sheet offers come from the server capability list on provider arms, so a + * GitLab MR never offers `request changes` (the provider has no such event). + */ export function PrReviewReviewSubmitScreen() { const router = useRouter(); const colors = useThemeColors(); const { t } = useTranslation(); const params = useLocalSearchParams(); - const owner = parseParam(params.owner) ?? ''; - const repo = parseParam(params.repo) ?? ''; - const rawNumber = parseParam(params.number) ?? ''; + + // The provider route carries the identity segments; the GitHub route the + // plain triple. Exactly one parses — the provider layout redirects a + // hand-built `/pr-review/github/...` link to the GitHub route. + const providerRef = useMemo( + () => + parseProviderPrRoute({ + platform: params.platform, + identity: params.identity, + instance: params.instance, + }), + [params.platform, params.identity, params.instance] + ); + const providerTriple = providerRef ? providerPrTriple(providerRef) : null; + const owner = providerTriple ? providerTriple.owner : (parseParam(params.owner) ?? ''); + const repo = providerTriple ? providerTriple.repo : (parseParam(params.repo) ?? ''); + const rawNumber = providerTriple + ? String(providerTriple.number) + : (parseParam(params.number) ?? ''); const number = Number.parseInt(rawNumber, 10); + const title = t('prReview.submit.submitReview'); - const eyebrow = `${owner}/${repo}#${rawNumber}`; + const eyebrow = providerRef ? providerPrRefLabel(providerRef) : `${owner}/${repo}#${rawNumber}`; const dismiss = () => { router.back(); }; + // The scope the overview runs under: the layout publishes the provider + // scope in context; the GitHub route falls back to the parsed triple, so + // the GitHub query key is byte-identical to the pre-s6 one. + const scope = useProviderPrScope( + owner && repo && Number.isInteger(number) && number > 0 + ? { owner, repo, number } + : { owner: '', repo: '', number: 0 } + ); + const trpc = useTRPC(); - const pr = useQuery( - trpc.githubPrReview.getPullRequest.queryOptions( - { owner, repo, number }, - { enabled: Boolean(owner) && Boolean(repo) && Number.isInteger(number) && number > 0 } - ) + const overviewOptions = useMemo(() => buildPrOverviewQueryOptions(trpc, scope), [trpc, scope]); + const paramsValid = Boolean(owner) && Boolean(repo) && Number.isInteger(number) && number > 0; + const pr = useQuery({ + ...overviewOptions, + enabled: paramsValid && isProviderScopeReady(scope), + }); + + // The server capability list, provider arms only. The github arm never + // touches the `providerReview` namespace (the GitHub path is unchanged): + // its query registers disabled and never fetches; the query also waits for + // a ready scope, so a Bitbucket PR without its organization waits instead + // of querying. + const isProviderArm = scope.ref.platform !== 'github'; + // The options call sits directly in the body and the result is bound + // before `useQuery`: wrapped in a helper/useMemo closure the linter's + // inference loses the option type, and passed inline the type-checker's + // inference collapses every `.data` read. The data itself is read back + // through the seam's typed selector. + const capabilitiesOptions = trpc.providerReview.getCapabilities.queryOptions( + providerCapabilitiesIdentity(scope), + { enabled: isProviderArm && isProviderScopeReady(scope) } ); + const capabilities = useQuery(capabilitiesOptions); + const capabilitiesData = selectProviderCapabilitiesData(capabilities.data); + + const capabilitiesReady = !isProviderArm || capabilitiesData !== undefined; + const capabilitiesFailed = isProviderArm && capabilities.isError; - if (pr.data) { + if (pr.data && capabilitiesReady) { return ( ); } - const body: ReactNode = pr.isLoading ? ( - - - - ) : ( - { - void pr.refetch(); - }} - isRetrying={pr.isFetching} - /> - ); + const body: ReactNode = + pr.isLoading || (isProviderArm && capabilities.isLoading) ? ( + + + + ) : ( + { + if (capabilitiesFailed) { + void capabilities.refetch(); + } + void pr.refetch(); + }} + isRetrying={pr.isFetching || (isProviderArm && capabilities.isFetching)} + /> + ); return ( <> diff --git a/apps/mobile/src/components/pr-review/pr-review-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-screen.tsx index 1e19cd3b61..d05b4d1f39 100644 --- a/apps/mobile/src/components/pr-review/pr-review-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-screen.tsx @@ -9,6 +9,7 @@ import { PrMergePartialSuccessBanner } from '@/components/pr-review/merge/pr-mer import { PrReviewDiscussionTab } from '@/components/pr-review/pr-review-discussion-tab'; import { PrReviewFilesTab } from '@/components/pr-review/pr-review-files-tab'; import { PrReviewOverview } from '@/components/pr-review/pr-review-overview'; +import { providerPrSheetHref } from '@/components/pr-review/pr-review-provider-sheet-href'; import { type PrReviewTabId, PrReviewTabSelector, @@ -64,14 +65,16 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) { // P1-F-46b: push the review-submit route with the same params the // Files-tab `PrDiffFloatingActions` uses, so a clean PR (no queued - // comments) can still be approved from the Overview tab. + // comments) can still be approved from the Overview tab. On a provider + // arm the sheet is a sibling of the ref's own route (s6), so the push + // carries the provider identity through the provider route. const openReviewSubmit = useCallback(() => { - const href: Href = { - pathname: REVIEW_SUBMIT_PATH, - params: { owner, repo, number }, - }; + const href: Href = + queries.ref.platform === 'github' + ? { pathname: REVIEW_SUBMIT_PATH, params: { owner, repo, number } } + : providerPrSheetHref(queries.ref, 'review-submit'); router.push(href); - }, [router, owner, repo, number]); + }, [router, owner, repo, number, queries.ref]); // P0-B-08: post-merge "branch delete failed" partial-success banner. // The merge sheet writes the reason into the in-memory store right @@ -184,10 +187,11 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) { }, [queryClient, queries, pr.data?.headSha]); const isMergeRequest = queries.platform === 'gitlab'; - // The review-submit sheet is a GitHub-route sibling; the provider write - // surfaces land with the provider write slice, so the affordance is only - // offered where it can actually be reached. - const canSubmitReview = isGitHub; + // The review-submit sheet is a route sibling on every provider (s6), so + // the affordance is offered wherever its scope can actually be queried: + // a Bitbucket PR without a selected organization waits at the boundary + // instead of opening a sheet that cannot load. + const canSubmitReview = queries.isReady; let body: ReactNode = null; if (!queries.isReady) { 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 8039eff5ab..d36c15ce72 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 @@ -1,9 +1,11 @@ +/* eslint-disable max-lines -- the submit suite covers queue retention, the footer preference, and the refused-submit wording wiring in one cohesive file */ /* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom); see src/lib/pr-review/pending-review-provider.mounted.test.tsx */ /* eslint-disable require-await, @typescript-eslint/require-await -- the fake mutation and drafts factories settle without await because they resolve immediately */ import TestRenderer, { act } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { PrReviewSubmit } from './pr-review-submit'; +import { type ProviderPrRef } from '@/lib/pr-review/provider-pr-ref'; import { type PendingReviewItem, PendingReviewProvider, @@ -12,6 +14,7 @@ import { const submitMutationMock = vi.hoisted(() => ({ mutateAsync: vi.fn(async (): Promise => undefined), + error: null as Error | null, })); const feedbackMock = vi.hoisted(() => ({ @@ -22,7 +25,7 @@ vi.mock('@/lib/pr-review/use-pr-review-mutations', () => ({ useSubmitReviewMutation: () => ({ mutateAsync: submitMutationMock.mutateAsync, isPending: false, - error: null, + error: submitMutationMock.error, }), })); @@ -147,7 +150,13 @@ function Consumer() { const SUBMIT_TITLE = 'Submit review'; -function mount(): TestRenderer.ReactTestRenderer { +const GITLAB_REF: ProviderPrRef = { + platform: 'gitlab', + projectPath: 'acme/kilo', + mrIid: 42, +}; + +function mount(prRef?: ProviderPrRef): TestRenderer.ReactTestRenderer { let renderer: TestRenderer.ReactTestRenderer | undefined = undefined; act(() => { renderer = TestRenderer.create( @@ -160,6 +169,7 @@ function mount(): TestRenderer.ReactTestRenderer { headSha="head-1" title={SUBMIT_TITLE} eyebrow="acme/kilo#42" + prRef={prRef} onDismiss={vi.fn(() => undefined)} /> @@ -212,6 +222,7 @@ beforeEach(() => { addCommentFn = null; submitMutationMock.mutateAsync.mockReset(); submitMutationMock.mutateAsync.mockResolvedValue(undefined); + submitMutationMock.error = null; feedbackMock.maybeAskAfterSuccessfulOutcome.mockReset(); feedbackMock.maybeAskAfterSuccessfulOutcome.mockResolvedValue(undefined); footerPreferenceMock.hasLoaded = true; @@ -309,3 +320,54 @@ describe('PrReviewSubmit footer preference', () => { expect(findSubmitButton(renderer).disabled).toBe(true); }); }); + +// ── s6f: refused-submit wording ────────────────────────────────────── + +/** Every string the mounted tree rendered inside a Text element. */ +function renderedTexts(renderer: TestRenderer.ReactTestRenderer): string[] { + return renderer.root + .findAll(node => typeof node.props.children === 'string') + .map(node => node.props.children as string); +} + +function badRequestError(): Error { + return Object.assign(new Error('You cannot perform the requested action'), { + data: { code: 'BAD_REQUEST' }, + }); +} + +describe('PrReviewSubmit refused-submit wording (s6f)', () => { + it('words a rejected provider submit after the merge-request noun', () => { + submitMutationMock.error = badRequestError(); + const renderer = mount(GITLAB_REF); + const texts = renderedTexts(renderer); + // A rejected review submit on a GitLab merge request must never read + // "your own pull request". + expect( + texts.some( + text => + text === + "This review can't be submitted as is. The merge request may have changed, or you can't review your own merge request." + ) + ).toBe(true); + expect( + texts.some( + text => + text === + "This review can't be submitted as is. The PR may have changed, or you can't review your own pull request." + ) + ).toBe(false); + }); + + it('keeps the exact pre-s6 submit copy on the GitHub arm', () => { + submitMutationMock.error = badRequestError(); + const renderer = mount(); + expect( + renderedTexts(renderer).some( + text => + text === + "This review can't be submitted as is. The PR may have changed, or you can't review your own pull request." + ) + ).toBe(true); + }); +}); 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 70163eee46..ccc554a260 100644 --- a/apps/mobile/src/components/pr-review/pr-review-submit.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-submit.tsx @@ -17,11 +17,15 @@ import { InteractionManager, Keyboard, Platform, + Pressable, ScrollView, type TextInput, View, } from 'react-native'; +import { RadioGroup, radioItemA11y } from '@/components/ui/radio-group'; +import { cn } from '@/lib/utils'; + import { PrFormSheetFooter, PrFormSheetHeader, @@ -44,14 +48,20 @@ import { reviewSubmitBlockReason, } from '@/lib/pr-review/build-submit-review-input'; import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice'; +import { providerPrNounKey } from '@/components/pr-review/pr-review-provider-noun'; import { ensureTermsAcceptedOutcome } from '@/components/pr-review/discussion/reply-input'; import { i18n } from '@/i18n'; 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, + type ProviderReviewEventOption, + useSubmitReviewMutation, +} from '@/lib/pr-review/use-pr-review-mutations'; import { type PendingReviewItem, usePendingReview } from '@/lib/pr-review/pending-review-provider'; import { partitionPendingItems } from '@/lib/pr-review/partition-pending-items'; -import { useSubmitReviewMutation } from '@/lib/pr-review/use-pr-review-mutations'; +import { type ProviderPrRef, providerPrRouteSegments } from '@/lib/pr-review/provider-pr-ref'; import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; import { usePrReviewFooterPreference } from '@/lib/hooks/use-pr-review-footer-preference'; import { maybeAskAfterSuccessfulOutcome } from '@/lib/feedback'; @@ -63,6 +73,83 @@ import { const COMMENT_COMPOSER_PATH = '/(app)/pr-review/[owner]/[repo]/[number]/comment-composer' as const; +/** The review event a provider `submitReview` input accepts, per chip event. */ +const EVENT_TO_PROVIDER = { + APPROVE: 'approve', + REQUEST_CHANGES: 'request_changes', + COMMENT: 'comment', +} satisfies Record; + +/** The chip label for one capability review event. */ +const PROVIDER_EVENT_LABEL_KEYS = { + approve: 'common.approve', + request_changes: 'prReview.eventChips.requestChanges', + comment: 'prReview.eventChips.comment', +} satisfies Record; + +/** + * Event chips driven by the server capability list (s6). The shared + * `ReviewEventChips` renders GitHub's three fixed options; on provider arms + * the capability list decides what is offered — a GitLab MR never offers + * request changes — so this renders only the events the provider accepts. + */ +function CapabilityReviewEventChips(props: { + events: readonly ProviderReviewEventOption[]; + value: ReviewEvent; + disabled: boolean; + onChange: (next: ReviewEvent) => void; +}) { + const { t } = useTranslation(); + return ( + + + {t('prReview.eventChips.label')} + + + {props.events.map(providerEvent => { + const event = (Object.keys(EVENT_TO_PROVIDER) as ReviewEvent[]).find( + candidate => EVENT_TO_PROVIDER[candidate] === providerEvent + ); + if (!event) { + return null; + } + const active = props.value === event; + return ( + { + void Haptics.selectionAsync(); + props.onChange(event); + }} + {...radioItemA11y({ + label: t(PROVIDER_EVENT_LABEL_KEYS[providerEvent]), + checked: active, + disabled: props.disabled, + })} + className={cn( + 'min-h-11 items-center justify-center rounded-full border px-4 py-2 active:opacity-70', + active ? 'border-primary bg-primary' : 'bg-secondary', + !active && (props.disabled ? 'border-hair-soft' : 'border-border') + )} + > + + {t(PROVIDER_EVENT_LABEL_KEYS[providerEvent])} + + + ); + })} + + + ); +} + type PrReviewSubmitProps = Readonly<{ owner: string; repo: string; @@ -70,15 +157,27 @@ type PrReviewSubmitProps = Readonly<{ headSha: string; title: string; 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. + */ + prRef?: ProviderPrRef; + /** + * The review events the server capability list accepts (provider arms). + * Absent on GitHub, which keeps the fixed three-option chip row. + */ + reviewEvents?: readonly ProviderReviewEventOption[]; onDismiss: () => void; }>; export function PrReviewSubmit(props: PrReviewSubmitProps) { - const { owner, repo, number, headSha, title, eyebrow, onDismiss } = props; + const { owner, repo, number, headSha, title, eyebrow, prRef, reviewEvents, onDismiss } = props; const router = useRouter(); const pending = usePendingReview(); const { t } = useTranslation(); - const submitReview = useSubmitReviewMutation({ owner, repo, number }); + const submitReview = useSubmitReviewMutation(prRef ?? { owner, repo, number }); const { userId } = useCurrentUserId(); const { prReviewFooter, hasLoaded: prReviewFooterLoaded } = usePrReviewFooterPreference(); @@ -108,6 +207,10 @@ export function PrReviewSubmit(props: PrReviewSubmitProps) { const isSubmitting = submitReview.isPending; const queuedCount = pending.items.length; + // The provider platform as a stable primitive: the error effect words a + // rejected submit after the connected provider (s6f) without depending on + // the `prRef` object identity. + const providerPlatform = prRef?.platform; const { fresh, stale } = partitionPendingItems(pending.items, headSha); const staleIds = new Set(stale.map(item => item.id)); const blockReason = reviewSubmitBlockReason({ @@ -142,11 +245,17 @@ export function PrReviewSubmit(props: PrReviewSubmitProps) { })(); return; } - const display = mutationErrorDisplay('submit', classification, submitReview.error); + // The provider arm words the rejected submit after the connected + // provider (merge request vs pull request); GitHub rides no term and + // keeps the exact pre-s6 copy. + const display = mutationErrorDisplay('submit', classification, { + rawError: submitReview.error, + term: providerPlatform ? i18n.t(providerPrNounKey(providerPlatform)) : undefined, + }); setInlineError(display.message); setInlineErrorKind(display.kind); } - }, [submitReview.error]); + }, [submitReview.error, providerPlatform]); useEffect(() => { const sub = Keyboard.addListener('keyboardDidShow', () => { @@ -182,16 +291,23 @@ 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). await submitReview.mutateAsync( - buildSubmitReviewInput({ - owner, - repo, - number, - event, - ...(body.length > 0 ? { body } : {}), - commitSha: headSha, - items: fresh, - }) + prRef + ? { + event: EVENT_TO_PROVIDER[event], + body: buildProviderSubmitBody(body, fresh), + } + : buildSubmitReviewInput({ + owner, + repo, + number, + event, + ...(body.length > 0 ? { body } : {}), + commitSha: headSha, + items: fresh, + }) ); pending.removeComments(fresh.map(item => item.id)); void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); @@ -214,6 +330,27 @@ export function PrReviewSubmit(props: PrReviewSubmitProps) { } function openEditComposer(item: PendingReviewItem) { + if (prRef) { + // The pending comment edits through the ref's own composer route, so + // the composer stays inside the scope its queries run under. + const { platform, identity } = providerPrRouteSegments(prRef); + const encoded = identity.map(segment => encodeURIComponent(segment)).join('/'); + const href: Href = { + pathname: `/(app)/pr-review/${platform}/${encoded}/comment-composer`, + params: { + path: item.path, + side: item.side, + line: String(item.line), + ...(item.startLine !== undefined ? { startLine: String(item.startLine) } : {}), + pendingId: item.id, + ...(prRef.platform === 'gitlab' && prRef.instanceHint + ? { instance: prRef.instanceHint } + : {}), + }, + }; + router.push(href); + return; + } const href: Href = { pathname: COMMENT_COMPOSER_PATH, params: { @@ -286,14 +423,26 @@ export function PrReviewSubmit(props: PrReviewSubmitProps) { keyboardDismissMode="interactive" > - { - setEvent(next); - clearRecoverableError(); - }} - /> + {reviewEvents ? ( + { + setEvent(next); + clearRecoverableError(); + }} + /> + ) : ( + { + setEvent(next); + clearRecoverableError(); + }} + /> + )} {t('prReview.submit.summaryOptional')} diff --git a/apps/mobile/src/components/pr-review/recent-pr-row-state.test.ts b/apps/mobile/src/components/pr-review/recent-pr-row-state.test.ts deleted file mode 100644 index 9a16af0c47..0000000000 --- a/apps/mobile/src/components/pr-review/recent-pr-row-state.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { selectRecentPrRowState } from './recent-pr-row-state'; -import { type RecentPr } from '@/lib/pr-review/recent-prs'; - -function makeRecent(overrides: Partial = {}): RecentPr { - return { - owner: 'octocat', - repo: 'hello-world', - number: 42, - title: 'Hello PR', - lastOpenedAt: 1_700_000_000_000, - ...overrides, - }; -} - -describe('selectRecentPrRowState', () => { - it('uses the title as primary and the identity as secondary', () => { - expect(selectRecentPrRowState(makeRecent({ title: 'Hello PR' }))).toEqual({ - primary: 'Hello PR', - secondary: 'octocat/hello-world#42', - failed: false, - }); - }); - - it('drops the secondary line when the title is empty (no duplicate)', () => { - expect(selectRecentPrRowState(makeRecent({ title: '' }))).toEqual({ - primary: 'octocat/hello-world#42', - secondary: null, - failed: false, - }); - }); - - it('marks a failed entry', () => { - expect(selectRecentPrRowState(makeRecent({ lastResult: 'failed' }))).toEqual({ - primary: 'Hello PR', - secondary: 'octocat/hello-world#42', - failed: true, - }); - }); - - it('treats a missing lastResult as ok', () => { - expect(selectRecentPrRowState(makeRecent()).failed).toBe(false); - }); -}); diff --git a/apps/mobile/src/components/pr-review/recent-pr-row-state.ts b/apps/mobile/src/components/pr-review/recent-pr-row-state.ts deleted file mode 100644 index 806037cfb1..0000000000 --- a/apps/mobile/src/components/pr-review/recent-pr-row-state.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Pure row-state selection for a recents entry. Extracted so the -// duplicated fallback line (title-less entries printing the identity -// twice) and the failed-entry marker are unit-tested. - -import { type RecentPr } from '@/lib/pr-review/recent-prs'; - -export type RecentPrRowState = { - /** Title when non-empty, else the identity line. */ - primary: string; - /** The identity line, or null when it would repeat `primary`. */ - secondary: string | null; - /** True when the entry's last load attempt failed. */ - failed: boolean; -}; - -export function selectRecentPrRowState(entry: RecentPr): RecentPrRowState { - const identity = `${entry.owner}/${entry.repo}#${entry.number}`; - const primary = entry.title.length > 0 ? entry.title : identity; - return { - primary, - secondary: primary === identity ? null : identity, - failed: entry.lastResult === 'failed', - }; -} diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index bd559dff59..96077b2ab8 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -36,6 +36,7 @@ "cost": "Cost", "discard": "Discard", "findings": "Findings", + "mergeRequest": "merge request", "notConnected": "Not connected", "notifications": "Notifications", "pullRequest": "pull request", @@ -333,10 +334,17 @@ "emptyDiffRow": "Empty diff row" }, "connectionExpired": "GitHub connection expired.", + "capabilities": { + "banner": { + "title": "Not available on this provider", + "accessibility": "Not available on this provider: {{reason}}" + } + }, "merge": { "merge": "Merge", "mergeNow": "Merge now", "mergePullRequest": "Merge pull request", + "mergeTermTitle": "Merge {{term}}", "enableAutoMerge": "Enable auto-merge", "disableAutoMerge": "Disable auto-merge", "autoMergeOn": "Auto-merge is on", @@ -355,9 +363,12 @@ "deleteBranchAfterMerge": "Delete branch after merge", "noEnabledMethods": "This repository has no enabled merge methods. Ask a repository admin to enable merge, squash, or rebase merging.", "confirmTitle": "Merge pull request?", + "confirmTitleTerm": "Merge {{term}}?", "confirmMessage": "This will merge your changes into the base branch.", "enableAutoMergeConfirmTitle": "Enable auto-merge?", "enableAutoMergeConfirmMessage": "GitHub will merge this pull request automatically when all required checks pass.", + "enableAutoMergeConfirmMessageTerm": "The {{term}} will merge automatically once its pipeline succeeds.", + "enableAutoMergeDescriptionTerm": "GitLab merges this {{term}} automatically once its pipeline succeeds.", "forbidden": "You don't have permission to merge this pull request.", "cannotMerge": "This pull request cannot be merged as is.", "couldNotMerge": "Could not merge pull request.", @@ -368,7 +379,8 @@ }, "terminal": { "alreadyMerged": "Already merged", - "closed": "This pull request is closed" + "closed": "This pull request is closed", + "closedMergeRequest": "This merge request is closed" }, "partialSuccessBanner": { "body": "Couldn't delete the branch: {{reason}}", @@ -394,6 +406,19 @@ "notMergeableTitle": "This pull request is not mergeable yet", "notMergeableDetail": "GitHub reported a {{state}} mergeable state." }, + "restrictions": { + "title": "Merge restrictions", + "approvalsRequired_one": "{{displayCount}} approval required", + "approvalsRequired": "{{displayCount}} approvals required", + "approvalsRequired_other": "{{displayCount}} approvals required", + "pipelineMustSucceed": "The pipeline must succeed before merging." + }, + "providerBlocked": { + "failingPipeline": "The pipeline is failing on the latest commit.", + "pendingPipeline": "The pipeline is still running on the latest commit.", + "permission": "You don't have permission to merge this {{term}}.", + "draft": "Mark the {{term}} as ready for review before merging." + }, "methodLabels": { "merge": "Create a merge commit", "squash": "Squash and merge", @@ -441,6 +466,7 @@ "acceptTerms": "Accept", "replyBadRequest": "This reply can't be posted. The thread may have changed.", "replyForbidden": "You don't have permission to reply to this pull request.", + "replyForbiddenTerm": "You don't have permission to reply to this {{term}}.", "moderation": { "reportContent": { "terminal": "This comment can't be reported.", @@ -676,6 +702,7 @@ "mutationError": { "commentNotPosted": "This comment can't be posted. The selected line may have changed, or the PR may have been updated.", "reviewNotSubmitted": "This review can't be submitted as is. The PR may have changed, or you can't review your own pull request.", + "reviewNotSubmittedTerm": "This review can't be submitted as is. The {{term}} may have changed, or you can't review your own {{term}}.", "couldNotPostComment": "Could not post comment.", "couldNotSubmitReview": "Could not submit review. Check your connection and try again." }, @@ -683,6 +710,7 @@ "couldNotReply": "Could not reply.", "couldNotCompleteAction": "Could not complete this action.", "mergeResultDefaultError": "GitHub did not complete the merge.", + "mergeResultNotCompleted": "The {{term}} was not merged. Check its state and try again.", "ambiguous": "Couldn't confirm — check the PR before retrying.", "persistenceFailed": "We could not record this action. Please try again later.", "forbidden": "Forbidden" diff --git a/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts b/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts index 745d5c8b46..0b246d4b3f 100644 --- a/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts +++ b/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts @@ -30,7 +30,11 @@ import { type ProviderPrScope, useProviderPrScope, } from '@/lib/pr-review/provider-pr-ref'; -import { getViewedFiles, toggleViewedFile } from '@/lib/pr-review/viewed-files'; +import { + getViewedFiles, + toggleViewedFile, + type ViewedFilesRef, +} from '@/lib/pr-review/viewed-files'; import { withInfiniteRetention } from '@/lib/query/infinite-retention'; import { useTRPC } from '@/lib/trpc'; @@ -100,12 +104,13 @@ export function usePrReviewFileListQuery(args: { } /** - * Subscribes the viewed-files store for a specific PR (keyed by - * `owner/repo#number` + `headSha`). Returns the current viewed path - * set plus a `toggle` callback that flips a single path. The - * underlying store is a single SecureStore key shared across all - * PRs, so the hook re-reads on toggle rather than maintaining a - * long-lived in-memory cache. + * Subscribes the viewed-files store for a specific PR (keyed by the ref's + * provider-scoped identity + `headSha`, so a GitLab MR and a same-numbered + * GitHub PR — or one project on two GitLab instances — never share a set; + * identity rule 17). Returns the current viewed path set plus a `toggle` + * callback that flips a single path. The underlying store is a single + * SecureStore key shared across all PRs, so the hook re-reads on toggle + * rather than maintaining a long-lived in-memory cache. */ // Module-level notifier so every mounted viewed-files hook (e.g. the diff list // AND the file navigator sheet mounted over it) re-reads after any toggle, @@ -118,15 +123,7 @@ function notifyViewedChange(): void { } } -export function usePrReviewViewedFiles( - ref: { - owner: string; - repo: string; - number: number; - }, - headSha: string -) { - const { owner, repo, number } = ref; +export function usePrReviewViewedFiles(ref: ViewedFilesRef, headSha: string) { const [paths, setPaths] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -136,7 +133,7 @@ export function usePrReviewViewedFiles( async function load() { try { - const next = await getViewedFiles({ owner, repo, number }, headSha); + const next = await getViewedFiles(ref, headSha); if (!cancelled) { setPaths(next); setIsLoading(false); @@ -160,7 +157,7 @@ export function usePrReviewViewedFiles( cancelled = true; viewedChangeListeners.delete(onChange); }; - }, [owner, repo, number, headSha]); + }, [ref, headSha]); const toggle = useCallback( async (path: string) => { @@ -172,11 +169,11 @@ export function usePrReviewViewedFiles( } return [...previous, path]; }); - await toggleViewedFile({ owner, repo, number, headSha, path }); + await toggleViewedFile({ ...ref, headSha, path }); // Notify other mounted instances (they re-read the durable store). notifyViewedChange(); }, - [owner, repo, number, headSha] + [ref, headSha] ); // Stabilize identities for downstream memos (`items`, `renderItem`). A fresh diff --git a/apps/mobile/src/lib/pr-review/diff/use-pr-review-viewed-files.mounted.test.tsx b/apps/mobile/src/lib/pr-review/diff/use-pr-review-viewed-files.mounted.test.tsx new file mode 100644 index 0000000000..070273ab07 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/use-pr-review-viewed-files.mounted.test.tsx @@ -0,0 +1,92 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as use-fetch-to-completion.mounted.test.tsx) */ +// s6 (identity rule 17): the viewed-files hook is provider-scoped end to +// end. The store folds `providerPrRefKey` into the durable key only when it +// receives the ref, so a GitLab MR and a same-numbered GitHub PR — or one +// project on two GitLab instances — never share a viewed set. These tests +// pin the hook's pass-through; the store's keying itself is covered in +// `viewed-files.test.ts`. + +import { createElement } from 'react'; +import { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { usePrReviewViewedFiles } from '@/lib/pr-review/diff/pr-review-file-list-state'; +import { type ProviderPrRef } from '@/lib/pr-review/provider-pr-ref'; +import { renderWithProviders } from '@/test/render-with-providers'; + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + githubPrReview: { + listFiles: { + infiniteQueryOptions: () => ({}), + }, + }, + }), +})); + +const storeMocks = vi.hoisted(() => ({ + getViewedFiles: vi.fn(), + toggleViewedFile: vi.fn(), +})); + +vi.mock('@/lib/pr-review/viewed-files', () => storeMocks); + +const GITLAB_REF: ProviderPrRef = { + platform: 'gitlab', + projectPath: 'group/sub/repo', + mrIid: 12, + instanceHint: 'https://gitlab.example.com', +}; + +type ViewedRefArg = Parameters[0]; + +type ProbeHolder = { + current: { isViewed: (path: string) => boolean; toggle: (path: string) => Promise } | null; +}; + +function Probe({ viewRef, holder }: { viewRef: ViewedRefArg; holder: ProbeHolder }) { + holder.current = usePrReviewViewedFiles(viewRef, 'sha-1'); + return null; +} + +async function mountProbe(viewRef: ViewedRefArg) { + const holder: ProbeHolder = { current: null }; + await renderWithProviders(createElement(Probe, { viewRef, holder })); + await act(async () => { + await Promise.resolve(); + }); + if (!holder.current) { + throw new Error('probe did not render'); + } + return holder.current; +} + +beforeEach(() => { + storeMocks.getViewedFiles.mockReset().mockResolvedValue([] as string[]); + storeMocks.toggleViewedFile.mockReset().mockResolvedValue(undefined); +}); + +describe('usePrReviewViewedFiles provider refs (s6)', () => { + it('reads the viewed set under the provider ref, not the bare triple', async () => { + await mountProbe(GITLAB_REF); + expect(storeMocks.getViewedFiles).toHaveBeenCalledWith(GITLAB_REF, 'sha-1'); + }); + + it('writes the toggle under the provider ref, folding the key identity', async () => { + const viewed = await mountProbe(GITLAB_REF); + await act(async () => { + await viewed.toggle('src/a.ts'); + }); + expect(storeMocks.toggleViewedFile).toHaveBeenCalledWith({ + ...GITLAB_REF, + headSha: 'sha-1', + path: 'src/a.ts', + }); + }); + + it('keeps the legacy GitHub triple working byte-for-byte', async () => { + const legacy = { owner: 'octocat', repo: 'hello-world', number: 7 }; + await mountProbe(legacy); + expect(storeMocks.getViewedFiles).toHaveBeenCalledWith(legacy, 'sha-1'); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts index dc26f01ad4..d95f5dc305 100644 --- a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts +++ b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts @@ -1,22 +1,32 @@ -// P1-A-08c wiring tests for `useReplyToCommentMutation`. +// P1-A-08c + s6 wiring tests for the discussion mutations. // // Replies are NOT optimistic (per the S7b contract): the comment is // appended only after the server confirms. These tests assert the HOOK -// WIRING — `mutationFn` delegates to -// `trpcClient.githubPrReview.replyToComment.mutate`, the hoisted operation -// key is merged into the input, and the key rotation policy (real +// WIRING — `mutationFn` delegates to the matching +// `trpcClient...mutate`, the hoisted operation key is +// merged into the input, and the key rotation policy (real // `isPrMutationRetryable` + `mapPrOperationError`) runs inside // `mutationFn`. Only `useHoistedOperationKey` is mocked (it holds React // ref state that needs a mounted renderer, covered by // `operation-key.mounted.test.tsx`). -/* eslint-disable max-lines -- one file for the reply wiring, the resolve/unresolve/reaction generation guard + chainSave/scope serialization, and the real-MutationCache scope.id serialization suites */ +// +// s6: the GitHub arms stay byte-identical. The provider arms route the +// same intents through `providerReview.*` with the s1 provider identity +// (GitLab keys fold `instanceHint`, Bitbucket folds `workspace`), reply +// and resolve carry the ledger key, and the optimistic resolve flips the +// provider-shaped cache (`resolved`, not `isResolved`). Reactions stay +// GitHub-only: no provider exposes them through the seam. +/* eslint-disable max-lines -- one file for the reply wiring, the resolve/unresolve/reaction generation guard + chainSave/scope serialization, the real-MutationCache scope.id serialization suite, and the s6 provider arms */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type * as OperationKeyModule from '@/lib/operation-key'; import type * as ReactQuery from '@tanstack/react-query'; import { prIntentFingerprint } from '@kilocode/app-shared/pr-review'; +import type * as ProviderPrRefModule from '@/lib/pr-review/provider-pr-ref'; +import { type ProviderPrRef, type ProviderPrTriple } from '@/lib/pr-review/provider-pr-ref'; import { + applyProviderResolveToggle, useAddReactionMutation, useRemoveReactionMutation, useReplyToCommentMutation, @@ -38,6 +48,20 @@ vi.mock('@/lib/operation-key', async importOriginal => { return { ...actual, useHoistedOperationKey: () => hoistedKeys }; }); +// See the review-mutations test: the scope context hook is replaced by a +// settable override so the hooks run without a renderer. No-arg calls +// keep the pre-s6 GitHub fallback. +let scopeOverride: { ref: ProviderPrRef; organizationId: string | null } | null = null; + +vi.mock('@/lib/pr-review/provider-pr-ref', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useProviderPrScope: (fallback: ProviderPrTriple) => + scopeOverride ?? { ref: { platform: 'github', ...fallback }, organizationId: null }, + }; +}); + type MutationOptions = { mutationFn?: (vars: unknown) => Promise; onMutate?: (vars: unknown) => Promise | unknown; @@ -60,6 +84,9 @@ let lastCapturedOptions: MutationOptions | null = null; const replyMutateMock = vi.fn(); const resolveMutateMock = vi.fn(); const unresolveMutateMock = vi.fn(); +const providerReplyMutateMock = vi.fn(); +const providerResolveMutateMock = vi.fn(); +const providerUnresolveMutateMock = vi.fn(); const invalidateQueriesMock = vi.fn(); const cancelQueriesMock = vi.fn(); const getQueriesDataMock = vi.fn(); @@ -92,6 +119,9 @@ vi.mock('@/lib/trpc', () => ({ addReaction: { mutationOptions: (opts: MutationOptions) => opts }, removeReaction: { mutationOptions: (opts: MutationOptions) => opts }, }, + providerReview: { + listDiscussions: { pathFilter: () => ['providerReview', 'listDiscussions'] }, + }, }), trpcClient: { githubPrReview: { @@ -102,6 +132,14 @@ vi.mock('@/lib/trpc', () => ({ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule unresolveThread: { mutate: (vars: unknown) => unresolveMutateMock(vars) }, }, + providerReview: { + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + replyToComment: { mutate: (vars: unknown) => providerReplyMutateMock(vars) }, + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + resolveThread: { mutate: (vars: unknown) => providerResolveMutateMock(vars) }, + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + unresolveThread: { mutate: (vars: unknown) => providerUnresolveMutateMock(vars) }, + }, }, })); @@ -109,6 +147,18 @@ vi.mock('sonner-native', () => ({ toast: { error: (msg: string) => toastErrorMock(msg) }, })); +// Rolldown (Vitest's bundler) cannot parse React Native's Flow source. +// The s6 provider helpers live in `use-pr-review-mutations`, which imports +// `announcingToast` -> `announce.ts` -> `react-native`. Mock only the +// symbols `announce.ts` imports so the module graph loads under Node. +vi.mock('react-native', () => ({ + AccessibilityInfo: { + announceForAccessibility: vi.fn(), + setAccessibilityFocus: vi.fn(), + }, + findNodeHandle: vi.fn(() => null), +})); + const REPLY_INPUT = { owner: 'octocat', repo: 'hello', @@ -117,20 +167,58 @@ const REPLY_INPUT = { body: 'good point', }; +const GITLAB_REF: ProviderPrRef = { + platform: 'gitlab', + projectPath: 'group/sub/app', + mrIid: 12, + instanceHint: 'https://gl.example.com', +}; + +const BITBUCKET_REF: ProviderPrRef = { + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'widgets', + prId: 77, +}; + +const GITLAB_IDENTITY = { + platform: 'gitlab', + projectPath: 'group/sub/app', + mrIid: 12, + instanceHint: 'https://gl.example.com', + organizationId: 'org-9', +}; + +const BITBUCKET_IDENTITY = { + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'widgets', + prId: 77, + organizationId: 'org-9', +}; + +function resetMocks() { + lastCapturedOptions = null; + scopeOverride = null; + replyMutateMock.mockReset(); + resolveMutateMock.mockReset(); + unresolveMutateMock.mockReset(); + providerReplyMutateMock.mockReset(); + providerResolveMutateMock.mockReset(); + providerUnresolveMutateMock.mockReset(); + invalidateQueriesMock.mockReset(); + cancelQueriesMock.mockReset(); + getQueriesDataMock.mockReset(); + setQueriesDataMock.mockReset(); + setQueryDataMock.mockReset(); + toastErrorMock.mockReset(); + hoistedKeys.getKey.mockClear(); + hoistedKeys.rotateKey.mockClear(); +} + describe('useReplyToCommentMutation (P1-A-08c wiring)', () => { beforeEach(() => { - lastCapturedOptions = null; - replyMutateMock.mockReset(); - resolveMutateMock.mockReset(); - unresolveMutateMock.mockReset(); - invalidateQueriesMock.mockReset(); - cancelQueriesMock.mockReset(); - getQueriesDataMock.mockReset(); - setQueriesDataMock.mockReset(); - setQueryDataMock.mockReset(); - toastErrorMock.mockReset(); - hoistedKeys.getKey.mockClear(); - hoistedKeys.rotateKey.mockClear(); + resetMocks(); }); afterEach(() => { @@ -223,6 +311,64 @@ describe('useReplyToCommentMutation (P1-A-08c wiring)', () => { }); }); +describe('useReplyToCommentMutation (s6 provider arms)', () => { + beforeEach(() => { + resetMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('gitlab: replies inside the discussion through providerReview.replyToComment', async () => { + scopeOverride = { ref: GITLAB_REF, organizationId: 'org-9' }; + providerReplyMutateMock.mockResolvedValueOnce({ done: true, replayed: false }); + useReplyToCommentMutation(GITLAB_REF); + + await expect( + lastCapturedOptions?.mutationFn?.({ threadId: 'd-1', commentNodeId: 'c-1', body: 'ok' }) + ).resolves.toEqual({ done: true, replayed: false }); + expect(replyMutateMock).not.toHaveBeenCalled(); + expect(providerReplyMutateMock).toHaveBeenCalledWith({ + ...GITLAB_IDENTITY, + discussionId: 'd-1', + body: 'ok', + operationKey: 'hoisted-op-key', + }); + // GitLab keys fold the instance hint and use the DISCUSSION id as the + // fingerprint commentId (mirrors the server's reply fingerprint input). + expect(hoistedKeys.getKey).toHaveBeenCalledWith( + '{"resource":["gitlab","https://gl.example.com","group/sub/app",12],"commentId":"d-1","body":"ok"}' + ); + }); + + it('bitbucket: replies attach to the parent comment id, keyed by workspace', async () => { + scopeOverride = { ref: BITBUCKET_REF, organizationId: 'org-9' }; + providerReplyMutateMock.mockResolvedValueOnce({ done: true, replayed: false }); + useReplyToCommentMutation(BITBUCKET_REF); + + await lastCapturedOptions?.mutationFn?.({ threadId: '9', commentNodeId: 'c-1', body: 'ok' }); + expect(providerReplyMutateMock).toHaveBeenCalledWith({ + ...BITBUCKET_IDENTITY, + commentId: 'c-1', + body: 'ok', + operationKey: 'hoisted-op-key', + }); + expect(hoistedKeys.getKey).toHaveBeenCalledWith( + '{"resource":["bitbucket","acme","widgets",77],"commentId":"c-1","body":"ok"}' + ); + }); + + it('onSettled invalidates the provider discussions cache', async () => { + scopeOverride = { ref: GITLAB_REF, organizationId: 'org-9' }; + useReplyToCommentMutation(GITLAB_REF); + + await lastCapturedOptions?.onSettled?.(); + + expect(invalidateQueriesMock).toHaveBeenCalledWith(['providerReview', 'listDiscussions']); + }); +}); + describe('reply_comment fingerprint (P1-A-08c changed-input)', () => { it('stays stable for a retry of the same reply and rotates when the body or target changes', () => { const original = prIntentFingerprint('reply_comment', REPLY_INPUT); @@ -244,13 +390,7 @@ describe('reply_comment fingerprint (P1-A-08c changed-input)', () => { describe('useResolveThreadMutation (generation guard + chainSave)', () => { beforeEach(() => { - lastCapturedOptions = null; - resolveMutateMock.mockReset(); - cancelQueriesMock.mockReset(); - getQueriesDataMock.mockReset(); - setQueriesDataMock.mockReset(); - setQueryDataMock.mockReset(); - toastErrorMock.mockReset(); + resetMocks(); }); it('wraps the tRPC call in chainSave keyed by threadId (rule 3) and adds no scope.id', async () => { @@ -330,13 +470,127 @@ describe('useResolveThreadMutation (generation guard + chainSave)', () => { }); }); +describe('useResolveThreadMutation / useUnresolveThreadMutation (s6 provider arms)', () => { + beforeEach(() => { + resetMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('gitlab: resolves by discussion id with the ledger key and instance-scoped fingerprint', async () => { + scopeOverride = { ref: GITLAB_REF, organizationId: 'org-9' }; + providerResolveMutateMock.mockResolvedValueOnce({ done: true, replayed: false }); + useResolveThreadMutation(GITLAB_REF); + + await expect(lastCapturedOptions?.mutationFn?.({ threadId: 'd-1' })).resolves.toEqual({ + done: true, + replayed: false, + }); + expect(resolveMutateMock).not.toHaveBeenCalled(); + expect(providerResolveMutateMock).toHaveBeenCalledWith({ + ...GITLAB_IDENTITY, + discussionId: 'd-1', + operationKey: 'hoisted-op-key', + }); + expect(hoistedKeys.getKey).toHaveBeenCalledWith( + '{"resource":["gitlab","https://gl.example.com","group/sub/app",12],"threadId":"d-1"}' + ); + }); + + it('bitbucket: unresolves by thread id with the workspace-scoped fingerprint', async () => { + scopeOverride = { ref: BITBUCKET_REF, organizationId: 'org-9' }; + providerUnresolveMutateMock.mockResolvedValueOnce({ done: true, replayed: false }); + useUnresolveThreadMutation(BITBUCKET_REF); + + await lastCapturedOptions?.mutationFn?.({ threadId: 'c-9' }); + expect(providerUnresolveMutateMock).toHaveBeenCalledWith({ + ...BITBUCKET_IDENTITY, + threadId: 'c-9', + operationKey: 'hoisted-op-key', + }); + expect(hoistedKeys.getKey).toHaveBeenCalledWith( + '{"resource":["bitbucket","acme","widgets",77],"threadId":"c-9"}' + ); + }); + + it('optimistically flips the provider-shaped cache (resolved, not isResolved) and rolls back on failure', async () => { + scopeOverride = { ref: GITLAB_REF, organizationId: 'org-9' }; + providerResolveMutateMock.mockRejectedValueOnce(new Error('boom')); + useResolveThreadMutation(GITLAB_REF); + const opts = lastCapturedOptions; + if (!opts) { + throw new Error('resolve options not captured'); + } + + const cached = { + pages: [ + { + threads: [ + { threadId: 'd-1', resolved: false }, + { threadId: 'd-2', resolved: false }, + ], + nextCursor: null, + }, + ], + }; + getQueriesDataMock.mockReturnValueOnce([['k1', cached]]); + const context = await opts.onMutate?.({ threadId: 'd-1' }); + + const updater = setQueriesDataMock.mock.calls[0]?.[1] as (old: unknown) => typeof cached; + expect(updater(cached).pages[0]?.threads).toEqual([ + { threadId: 'd-1', resolved: true }, + { threadId: 'd-2', resolved: false }, + ]); + + setQueryDataMock.mockClear(); + await expect(opts.mutationFn?.({ threadId: 'd-1' })).rejects.toBeInstanceOf(Error); + opts.onError?.(new Error('boom'), { threadId: 'd-1' }, context); + expect(setQueryDataMock).toHaveBeenCalledWith('k1', cached); + expect(toastErrorMock).toHaveBeenCalledWith('boom'); + }); + + it('onSettled invalidates the provider discussions cache', async () => { + scopeOverride = { ref: GITLAB_REF, organizationId: 'org-9' }; + useResolveThreadMutation(GITLAB_REF); + + await lastCapturedOptions?.onSettled?.(); + + expect(invalidateQueriesMock).toHaveBeenCalledWith(['providerReview', 'listDiscussions']); + }); +}); + +describe('applyProviderResolveToggle (s6 optimistic reducer)', () => { + it('flips only the matching thread across every cached page', () => { + const pages = { + pages: [ + { threads: [{ threadId: 'a', resolved: false }], nextCursor: 'p2' }, + { + threads: [ + { threadId: 'b', resolved: false }, + { threadId: 'c', resolved: true }, + ], + nextCursor: null, + }, + ], + }; + const next = applyProviderResolveToggle(pages, 'b', true); + expect(next?.pages[0]?.threads).toEqual([{ threadId: 'a', resolved: false }]); + expect(next?.pages[1]?.threads).toEqual([ + { threadId: 'b', resolved: true }, + { threadId: 'c', resolved: true }, + ]); + }); + + it('passes an undefined cache through untouched', () => { + expect(applyProviderResolveToggle(undefined, 'b', true)).toBeUndefined(); + }); +}); + describe('useUnresolveThreadMutation (generation guard + chainSave)', () => { beforeEach(() => { - lastCapturedOptions = null; - unresolveMutateMock.mockReset(); - getQueriesDataMock.mockReset(); - setQueryDataMock.mockReset(); - toastErrorMock.mockReset(); + resetMocks(); }); it('wraps the tRPC call in chainSave keyed by threadId and adds no scope.id', async () => { @@ -371,10 +625,7 @@ describe('useUnresolveThreadMutation (generation guard + chainSave)', () => { describe('useAddReactionMutation (generation guard + scope.id)', () => { beforeEach(() => { - lastCapturedOptions = null; - getQueriesDataMock.mockReset(); - setQueryDataMock.mockReset(); - toastErrorMock.mockReset(); + resetMocks(); }); it('scopes the mutation per thread from the hook closure (rule 2)', () => { @@ -401,10 +652,7 @@ describe('useAddReactionMutation (generation guard + scope.id)', () => { describe('useRemoveReactionMutation (generation guard + scope.id)', () => { beforeEach(() => { - lastCapturedOptions = null; - getQueriesDataMock.mockReset(); - setQueryDataMock.mockReset(); - toastErrorMock.mockReset(); + resetMocks(); }); it('scopes the mutation per thread from the hook closure (rule 2)', () => { diff --git a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts index 647d88ccd5..b605e12c84 100644 --- a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts +++ b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts @@ -1,4 +1,6 @@ -// Discussion-tab mutations for the PR review surface. +/* eslint-disable max-lines -- the reply, resolve-toggle, and reaction suites share one mutation harness for the discussion seam */ +// Discussion-tab mutations for the PR review surface, s6: provider-aware +// through the `providerReview` seam. // // - `replyToComment` — NOT optimistic (per S7b contract): // the comment is appended only after the @@ -11,20 +13,34 @@ // // - `resolveThread` / // `unresolveThread` — OPTIMISTIC. The reducer flips the -// thread's `isResolved` in the cached -// `listReviewThreads` infinite query, -// snapshots the previous data in -// `onMutate`, and rolls it back in -// `onError`. `onSettled` invalidates the -// path so a re-fetch reconciles with -// the server's eventual state. +// thread's resolved flag in the cached +// threads infinite query (GitHub's +// `listReviewThreads`, a provider's +// `listDiscussions`), snapshots the +// previous data in `onMutate`, and rolls +// it back in `onError`. `onSettled` +// invalidates the path so a re-fetch +// reconciles with the server's eventual +// state. // // - `addReaction` / // `removeReaction` — OPTIMISTIC. Same pattern as resolve, // but the reducer walks into a specific // comment inside a specific thread to // flip `count` + `viewerHasReacted`. -// Invalidates on settle. +// Invalidates on settle. GitHub-only: +// no provider exposes reactions through +// the seam (the capability flag gates +// the affordance in `CommentRow`). +// +// Provider arms (s6): every hook takes an optional `ProviderPrRef`. The +// discussion tree passes none and the hooks read the live provider scope +// from context, so the non-provider surface keeps the exact GitHub-shaped +// calls it made before. The thread id is provider-native on every arm +// (`ProviderPrThread.threadId` is GitLab's discussion id and Bitbucket's +// root-comment id verbatim), so `{threadId}` vars work unchanged; the +// reply vars additionally carry the provider comment id (`nodeId`) for +// Bitbucket, whose replies attach to a parent comment, not a thread. // // Why we do NOT coalesce these into the existing // `useCreateReviewCommentMutation` / `useSubmitReviewMutation` hooks: @@ -37,12 +53,22 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner-native'; import { prIntentFingerprint } from '@kilocode/app-shared/pr-review'; +import { type ProviderPrRef } from '@kilocode/app-shared/provider-review'; import { isLatestMutationGeneration, nextMutationGeneration, } from '@/lib/hooks/mutation-generations'; import { chainSave } from '@/lib/hooks/save-chain'; +import { + type ProviderPrScope, + providerPrTriple, + useProviderPrScope, +} from '@/lib/pr-review/provider-pr-ref'; +import { + providerFingerprintInput, + providerWriteIdentity, +} from '@/lib/pr-review/use-pr-review-mutations'; import { trpcClient, useTRPC } from '@/lib/trpc'; import { useHoistedOperationKey } from '@/lib/operation-key'; import { @@ -58,28 +84,88 @@ import { type ReviewThreadsInfiniteData, } from './review-discussion-types'; -function useDiscussionKeys() { - const trpc = useTRPC(); +/** One page of provider threads, at the runtime shape the read layer caches. */ +type ProviderThreadsPage = { + threads: { threadId: string; resolved: boolean }[]; + nextCursor: string | null; +}; + +/** The infinite container the provider threads query caches the pages in. */ +type ProviderThreadsCache = { + pages: ProviderThreadsPage[]; +}; + +/** Flip one provider thread's resolved flag across the cached pages. */ +export function applyProviderResolveToggle( + pages: { pages: T[] } | undefined, + threadId: string, + resolved: boolean +): { pages: T[] } | undefined { + if (!pages) { + return pages; + } return { - listReviewThreadsPath: trpc.githubPrReview.listReviewThreads.pathFilter(), + ...pages, + pages: pages.pages.map(page => + page.threads.some(thread => thread.threadId === threadId) + ? { + ...page, + threads: page.threads.map(thread => + thread.threadId === threadId ? { ...thread, resolved } : thread + ), + } + : page + ), }; } +/** + * The scope the discussion write runs under. With an explicit ref the + * caller's organization (from the live provider scope) rides along; with + * none the GitHub-shaped fallback keeps the pre-s6 behavior byte-for-byte. + */ +function useDiscussionScope(ref?: ProviderPrRef): ProviderPrScope { + const scope = useProviderPrScope( + ref ? providerPrTriple(ref) : { owner: '', repo: '', number: 0 } + ); + return ref ? { ref, organizationId: scope.organizationId } : scope; +} + // Every discussion mutation snapshots the same procedure-wide // `listReviewThreads` cache through its path filter, so one shared generation // key guards all rollbacks across resolve/unresolve/reaction writes. -const LIST_REVIEW_THREADS_GENERATION_KEY = 'githubPrReview.listReviewThreads'; +function useGithubDiscussionKeys() { + const trpc = useTRPC(); + return { + threadsPath: trpc.githubPrReview.listReviewThreads.pathFilter(), + generationKey: 'githubPrReview.listReviewThreads', + }; +} + +function useDiscussionKeys(scope: ProviderPrScope) { + const trpc = useTRPC(); + if (scope.ref.platform === 'github') { + return { + threadsPath: trpc.githubPrReview.listReviewThreads.pathFilter(), + generationKey: 'githubPrReview.listReviewThreads', + }; + } + return { + threadsPath: trpc.providerReview.listDiscussions.pathFilter(), + generationKey: 'providerReview.listDiscussions', + }; +} async function invalidateDiscussionCaches( queryClient: ReturnType, keys: ReturnType ): Promise { - await queryClient.invalidateQueries(keys.listReviewThreadsPath); + await queryClient.invalidateQueries(keys.threadsPath); } // ── Reply (not optimistic) ──────────────────────────────────────────── -export type ReplyToCommentInput = { +type ReplyToCommentInput = { owner: string; repo: string; number: number; @@ -87,18 +173,63 @@ export type ReplyToCommentInput = { body: string; }; -export function useReplyToCommentMutation() { +/** + * The provider reply vars: `threadId` is the GitLab discussion id (GitLab + * replies land inside a discussion); `commentNodeId` is the provider + * comment id Bitbucket attaches a reply to. Both carry the provider-native + * string ids the read layer keeps in `threadId` / `nodeId`. + */ +type ProviderReplyVars = { + threadId: string; + commentNodeId: string; + body: string; +}; +export type ReplyVars = ReplyToCommentInput | ProviderReplyVars; + +export function useReplyToCommentMutation(ref?: ProviderPrRef) { const queryClient = useQueryClient(); - const keys = useDiscussionKeys(); + const scope = useDiscussionScope(ref); + const keys = useDiscussionKeys(scope); const { getKey, rotateKey } = useHoistedOperationKey(); return useMutation({ - mutationFn: async (input: ReplyToCommentInput) => { + mutationFn: async (input: ReplyVars) => { try { - const result = await trpcClient.githubPrReview.replyToComment.mutate({ - ...input, - operationKey: getKey(prIntentFingerprint('reply_comment', input)), - }); + if (scope.ref.platform === 'github') { + const vars = input as ReplyToCommentInput; + const result = await trpcClient.githubPrReview.replyToComment.mutate({ + ...vars, + operationKey: getKey(prIntentFingerprint('reply_comment', vars)), + }); + rotateKey(); + return result; + } + const vars = input as ProviderReplyVars; + const commentId = scope.ref.platform === 'gitlab' ? vars.threadId : vars.commentNodeId; + const operationKey = getKey( + prIntentFingerprint( + 'reply_comment', + providerFingerprintInput(scope.ref, { commentId, body: vars.body }) + ) + ); + // One call per platform arm: a conditional spread inside a single + // literal would cross-multiply the identity union with the id + // union and hand the server gitlab+commentId shapes. + const identity = providerWriteIdentity(scope); + const result = + identity.platform === 'gitlab' + ? await trpcClient.providerReview.replyToComment.mutate({ + ...identity, + discussionId: vars.threadId, + body: vars.body, + operationKey, + }) + : await trpcClient.providerReview.replyToComment.mutate({ + ...identity, + commentId: vars.commentNodeId, + body: vars.body, + operationKey, + }); rotateKey(); return result; } catch (error) { @@ -119,36 +250,77 @@ export function useReplyToCommentMutation() { // ── Resolve / unresolve (optimistic) ────────────────────────────────── -export function useResolveThreadMutation() { +/** + * The optimistic resolve/unresolve write. The GitHub arm keeps the exact + * pre-s6 call (no operation key — the GitHub thread procedures are not + * ledgered); the provider arms ledger the write with the s1 provider + * fingerprint and flip the provider-shaped cache (`resolved`, not + * `isResolved`). + */ +function useResolveToggleMutation( + ref: ProviderPrRef | undefined, + resolved: boolean, + mutateGithub: (vars: { threadId: string }) => Promise +) { const queryClient = useQueryClient(); - const keys = useDiscussionKeys(); + const scope = useDiscussionScope(ref); + const keys = useDiscussionKeys(scope); + const { getKey, rotateKey } = useHoistedOperationKey(); // onError policy: roll back the onMutate snapshot (latest generation only) // and toast error.message. return useMutation({ + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule mutationFn: (vars: { threadId: string }) => - // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule - chainSave(`pr-thread:${vars.threadId}`, () => - trpcClient.githubPrReview.resolveThread.mutate(vars) + // The GitHub arm keeps the pre-s6 chainSave key so its serialization is + // unchanged; provider keys fold the platform to stay disjoint. + chainSave( + scope.ref.platform === 'github' + ? `pr-thread:${vars.threadId}` + : `pr-thread:${scope.ref.platform}:${vars.threadId}`, + async () => { + if (scope.ref.platform === 'github') { + return mutateGithub(vars); + } + try { + const input = providerResolveInput(scope, { + threadId: vars.threadId, + resolve: resolved, + getKey, + }); + const result = resolved + ? await trpcClient.providerReview.resolveThread.mutate(input) + : await trpcClient.providerReview.unresolveThread.mutate(input); + rotateKey(); + return result; + } catch (error) { + if (!isPrMutationRetryable(error)) { + rotateKey(); + } + throw mapPrOperationError(error, 'reply'); + } + } ), onMutate: async ({ threadId }) => { - await queryClient.cancelQueries(keys.listReviewThreadsPath); - const generation = nextMutationGeneration(LIST_REVIEW_THREADS_GENERATION_KEY); - const previous = queryClient.getQueriesData( - keys.listReviewThreadsPath - ); - queryClient.setQueriesData(keys.listReviewThreadsPath, old => - applyResolveToggle(old, threadId, true) + await queryClient.cancelQueries(keys.threadsPath); + const generation = nextMutationGeneration(keys.generationKey); + if (scope.ref.platform === 'github') { + const previous = queryClient.getQueriesData(keys.threadsPath); + queryClient.setQueriesData(keys.threadsPath, old => + applyResolveToggle(old, threadId, resolved) + ); + return { previous, generation }; + } + const previous = queryClient.getQueriesData(keys.threadsPath); + queryClient.setQueriesData(keys.threadsPath, old => + applyProviderResolveToggle(old, threadId, resolved) ); return { previous, generation }; }, onError: (error, _input, context) => { - if ( - context?.previous && - isLatestMutationGeneration(LIST_REVIEW_THREADS_GENERATION_KEY, context.generation) - ) { + if (context?.previous && isLatestMutationGeneration(keys.generationKey, context.generation)) { for (const [key, data] of context.previous) { - queryClient.setQueryData(key, data); + queryClient.setQueryData(key, data); } } toast.error(error.message); @@ -159,43 +331,44 @@ export function useResolveThreadMutation() { }); } -export function useUnresolveThreadMutation() { - const queryClient = useQueryClient(); - const keys = useDiscussionKeys(); +/** The provider `resolveThread` / `unresolveThread` input for one thread. */ +function providerResolveInput( + scope: ProviderPrScope, + args: { + threadId: string; + resolve: boolean; + getKey: (fingerprint: string) => string; + } +) { + const { threadId, resolve, getKey } = args; + const identity = providerWriteIdentity(scope); + const intent = resolve ? 'resolve_thread' : 'unresolve_thread'; + // The fingerprint rides the platform-narrowed identity: its gitlab/ + // bitbucket arms carry exactly the s1 ref fields the server hashes. + const fingerprint = providerFingerprintInput(identity, { threadId }); + if (identity.platform === 'gitlab') { + return { + ...identity, + discussionId: threadId, + operationKey: getKey(prIntentFingerprint(intent, fingerprint)), + }; + } + return { + ...identity, + threadId, + operationKey: getKey(prIntentFingerprint(intent, fingerprint)), + }; +} - // onError policy: roll back the onMutate snapshot (latest generation only) - // and toast error.message. - return useMutation({ - mutationFn: (vars: { threadId: string }) => - // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule - chainSave(`pr-thread:${vars.threadId}`, () => - trpcClient.githubPrReview.unresolveThread.mutate(vars) - ), - onMutate: async ({ threadId }) => { - await queryClient.cancelQueries(keys.listReviewThreadsPath); - const generation = nextMutationGeneration(LIST_REVIEW_THREADS_GENERATION_KEY); - const previous = queryClient.getQueriesData( - keys.listReviewThreadsPath - ); - queryClient.setQueriesData(keys.listReviewThreadsPath, old => - applyResolveToggle(old, threadId, false) - ); - return { previous, generation }; - }, - onError: (error, _input, context) => { - if ( - context?.previous && - isLatestMutationGeneration(LIST_REVIEW_THREADS_GENERATION_KEY, context.generation) - ) { - for (const [key, data] of context.previous) { - queryClient.setQueryData(key, data); - } - } - toast.error(error.message); - }, - onSettled: async () => { - await invalidateDiscussionCaches(queryClient, keys); - }, +export function useResolveThreadMutation(ref?: ProviderPrRef) { + return useResolveToggleMutation(ref, true, async vars => { + await trpcClient.githubPrReview.resolveThread.mutate(vars); + }); +} + +export function useUnresolveThreadMutation(ref?: ProviderPrRef) { + return useResolveToggleMutation(ref, false, async vars => { + await trpcClient.githubPrReview.unresolveThread.mutate(vars); }); } @@ -208,7 +381,7 @@ export function useUnresolveThreadMutation() { export function useAddReactionMutation(threadId: string) { const trpc = useTRPC(); const queryClient = useQueryClient(); - const keys = useDiscussionKeys(); + const keys = useGithubDiscussionKeys(); // onError policy: roll back the onMutate snapshot (latest generation only) // and toast error.message. @@ -216,12 +389,10 @@ export function useAddReactionMutation(threadId: string) { trpc.githubPrReview.addReaction.mutationOptions({ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule onMutate: async ({ commentNodeId, content }) => { - await queryClient.cancelQueries(keys.listReviewThreadsPath); - const generation = nextMutationGeneration(LIST_REVIEW_THREADS_GENERATION_KEY); - const previous = queryClient.getQueriesData( - keys.listReviewThreadsPath - ); - queryClient.setQueriesData(keys.listReviewThreadsPath, old => + await queryClient.cancelQueries(keys.threadsPath); + const generation = nextMutationGeneration(keys.generationKey); + const previous = queryClient.getQueriesData(keys.threadsPath); + queryClient.setQueriesData(keys.threadsPath, old => applyReactionToggle({ data: old, threadId, @@ -234,7 +405,7 @@ export function useAddReactionMutation(threadId: string) { onError: (error, _input, context) => { if ( context?.previous && - isLatestMutationGeneration(LIST_REVIEW_THREADS_GENERATION_KEY, context.generation) + isLatestMutationGeneration(keys.generationKey, context.generation) ) { for (const [key, data] of context.previous) { queryClient.setQueryData(key, data); @@ -256,7 +427,7 @@ export function useAddReactionMutation(threadId: string) { export function useRemoveReactionMutation(threadId: string) { const trpc = useTRPC(); const queryClient = useQueryClient(); - const keys = useDiscussionKeys(); + const keys = useGithubDiscussionKeys(); // onError policy: roll back the onMutate snapshot (latest generation only) // and toast error.message. @@ -264,12 +435,10 @@ export function useRemoveReactionMutation(threadId: string) { trpc.githubPrReview.removeReaction.mutationOptions({ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule onMutate: async ({ commentNodeId, content }) => { - await queryClient.cancelQueries(keys.listReviewThreadsPath); - const generation = nextMutationGeneration(LIST_REVIEW_THREADS_GENERATION_KEY); - const previous = queryClient.getQueriesData( - keys.listReviewThreadsPath - ); - queryClient.setQueriesData(keys.listReviewThreadsPath, old => + await queryClient.cancelQueries(keys.threadsPath); + const generation = nextMutationGeneration(keys.generationKey); + const previous = queryClient.getQueriesData(keys.threadsPath); + queryClient.setQueriesData(keys.threadsPath, old => applyReactionToggle({ data: old, threadId, @@ -282,7 +451,7 @@ export function useRemoveReactionMutation(threadId: string) { onError: (error, _input, context) => { if ( context?.previous && - isLatestMutationGeneration(LIST_REVIEW_THREADS_GENERATION_KEY, context.generation) + isLatestMutationGeneration(keys.generationKey, context.generation) ) { for (const [key, data] of context.previous) { queryClient.setQueryData(key, data); diff --git a/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.test.ts b/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.test.ts index 9f9d5fc516..7aa960aae5 100644 --- a/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.test.ts +++ b/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.test.ts @@ -1,4 +1,6 @@ -// P0-B-08 wiring tests for `useMergePullRequestMutation`. +/* eslint-disable max-lines -- the merge and auto-merge suites share one mock harness for the merge seam */ +// P0-B-08 + s6 wiring tests for `useMergePullRequestMutation` and the +// auto-merge hooks. // // The pure gate / store / error class are covered by their own unit // tests. These tests assert the WIRING: the hook's `mutationFn` @@ -11,13 +13,24 @@ // input and the key rotation policy (real `isPrMutationRetryable`) runs // inside `mutationFn`; only `useHoistedOperationKey` is mocked (it holds // React ref state that needs a mounted renderer). +// +// s6: the provider arms route the same intents through +// `providerReview.*` with the s1 provider identity, normalize the +// `{done, replayed}` answer onto the gate's result shape (a `done: +// false` never celebrates, with provider wording), and fence every +// merge/auto-merge write on `expectedHeadSha`. Auto-merge is +// GitLab-only: the server answers Bitbucket with `{supported: false, +// reason}` and the hook must NOT announce a success for it. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type * as OperationKeyModule from '@/lib/operation-key'; +import type * as AnnounceModule from '@/lib/a11y/announce'; import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; import { prIntentFingerprint } from '@kilocode/app-shared/pr-review'; -import { useMergePullRequestMutation } from './use-pr-merge-mutations'; +import type * as ProviderPrRefModule from '@/lib/pr-review/provider-pr-ref'; +import { type ProviderPrRef, type ProviderPrTriple } from '@/lib/pr-review/provider-pr-ref'; +import { useEnableAutoMergeMutation, useMergePullRequestMutation } from './use-pr-merge-mutations'; import { MergeNotCompletedError } from './merge-result-error'; const hoistedKeys = vi.hoisted(() => ({ @@ -25,6 +38,8 @@ const hoistedKeys = vi.hoisted(() => ({ rotateKey: vi.fn(), })); +const announceMock = vi.hoisted(() => ({ announceForA11y: vi.fn() })); + vi.mock('expo-crypto', () => ({ randomUUID: () => 'not-used', })); @@ -34,14 +49,35 @@ vi.mock('@/lib/operation-key', async importOriginal => { return { ...actual, useHoistedOperationKey: () => hoistedKeys }; }); +vi.mock('@/lib/a11y/announce', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, announceForA11y: (message: string) => announceMock.announceForA11y(message) }; +}); + +// See the review-mutations test: the scope context hook is replaced by a +// settable override so the hooks run without a renderer. +let scopeOverride: { ref: ProviderPrRef; organizationId: string | null } | null = null; + +vi.mock('@/lib/pr-review/provider-pr-ref', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useProviderPrScope: (fallback: ProviderPrTriple) => + scopeOverride ?? { ref: { platform: 'github', ...fallback }, organizationId: null }, + }; +}); + type MutationOptions = { mutationFn?: (vars: unknown) => Promise; + onSuccess?: (data: unknown) => void; onError?: (error: unknown) => void; - onSettled?: (data: unknown, error: unknown, vars: unknown) => Promise | void; + onSettled?: (data?: unknown, error?: unknown, vars?: unknown) => Promise | void; }; let lastCapturedOptions: MutationOptions | null = null; const mutateMock = vi.fn(); +const providerMergeMutateMock = vi.fn(); +const providerEnableAutoMergeMutateMock = vi.fn(); const invalidateQueriesMock = vi.fn(); const toastErrorMock = vi.fn(); @@ -64,12 +100,23 @@ vi.mock('@/lib/trpc', () => ({ listChecks: { pathFilter: () => ['githubPrReview', 'listChecks'] }, listFiles: { pathFilter: () => ['githubPrReview', 'listFiles'] }, }, + providerReview: { + getPullRequest: { queryKey: () => ['providerReview', 'getPullRequest'] }, + listChecks: { pathFilter: () => ['providerReview', 'listChecks'] }, + listFiles: { pathFilter: () => ['providerReview', 'listFiles'] }, + }, }), trpcClient: { githubPrReview: { // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule mergePullRequest: { mutate: (vars: unknown) => mutateMock(vars) }, }, + providerReview: { + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + mergePullRequest: { mutate: (vars: unknown) => providerMergeMutateMock(vars) }, + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + enableAutoMerge: { mutate: (vars: unknown) => providerEnableAutoMergeMutateMock(vars) }, + }, }, })); @@ -99,14 +146,60 @@ const INPUT = { expectedHeadSha: 'a'.repeat(40), }; +const GITLAB_REF: ProviderPrRef = { + platform: 'gitlab', + projectPath: 'group/sub/app', + mrIid: 12, + instanceHint: 'https://gl.example.com', +}; + +const BITBUCKET_REF: ProviderPrRef = { + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'widgets', + prId: 77, +}; + +const GITLAB_IDENTITY = { + platform: 'gitlab', + projectPath: 'group/sub/app', + mrIid: 12, + instanceHint: 'https://gl.example.com', + organizationId: 'org-9', +}; + +const BITBUCKET_IDENTITY = { + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'widgets', + prId: 77, + organizationId: 'org-9', +}; + +const PROVIDER_MERGE_VARS = { + expectedHeadSha: 'a'.repeat(40), + squash: true, + deleteBranch: true, + commitTitle: 'My title', + commitMessage: 'My message', +}; + +function resetMocks() { + lastCapturedOptions = null; + scopeOverride = null; + mutateMock.mockReset(); + providerMergeMutateMock.mockReset(); + providerEnableAutoMergeMutateMock.mockReset(); + invalidateQueriesMock.mockReset(); + toastErrorMock.mockReset(); + announceMock.announceForA11y.mockReset(); + hoistedKeys.getKey.mockClear(); + hoistedKeys.rotateKey.mockClear(); +} + describe('useMergePullRequestMutation (P0-B-08 wiring)', () => { beforeEach(() => { - lastCapturedOptions = null; - mutateMock.mockReset(); - invalidateQueriesMock.mockReset(); - toastErrorMock.mockReset(); - hoistedKeys.getKey.mockClear(); - hoistedKeys.rotateKey.mockClear(); + resetMocks(); }); afterEach(() => { @@ -259,6 +352,179 @@ describe('useMergePullRequestMutation (P0-B-08 wiring)', () => { }); }); +describe('useMergePullRequestMutation (s6 gitlab arm)', () => { + beforeEach(() => { + resetMocks(); + scopeOverride = { ref: GITLAB_REF, organizationId: 'org-9' }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('routes the fenced merge through providerReview.mergePullRequest and normalizes the result', async () => { + providerMergeMutateMock.mockResolvedValueOnce({ done: true, replayed: false }); + useMergePullRequestMutation(GITLAB_REF); + + await expect(lastCapturedOptions?.mutationFn?.(PROVIDER_MERGE_VARS)).resolves.toEqual({ + merged: true, + sha: 'a'.repeat(40), + branchDeleted: false, + }); + expect(mutateMock).not.toHaveBeenCalled(); + expect(providerMergeMutateMock).toHaveBeenCalledWith({ + ...GITLAB_IDENTITY, + expectedHeadSha: 'a'.repeat(40), + squash: true, + deleteBranch: true, + commitTitle: 'My title', + commitMessage: 'My message', + operationKey: 'hoisted-op-key', + }); + // Pinned bytes mirroring the server's gitlab merge fingerprint input: + // method folds the squash toggle, and the fence rides the identity. + expect(hoistedKeys.getKey).toHaveBeenCalledWith( + '{"resource":["gitlab","https://gl.example.com","group/sub/app",12],"method":"squash","commitTitle":"My title","commitMessage":"My message","deleteBranch":true,"expectedHeadSha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' + ); + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('never celebrates done:false: throws MergeNotCompletedError with merge-request wording, key kept', async () => { + providerMergeMutateMock.mockResolvedValueOnce({ done: false, replayed: false }); + useMergePullRequestMutation(GITLAB_REF); + + let thrown: unknown = null; + try { + await lastCapturedOptions?.mutationFn?.(PROVIDER_MERGE_VARS); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(MergeNotCompletedError); + expect((thrown as Error).message).toBe( + 'The merge request was not merged. Check its state and try again.' + ); + expect(classifyPrReviewMutationError(thrown)).toEqual({ kind: 'retryable' }); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('surfaces the server stale-head CONFLICT message unchanged (the sheet keeps it inline)', async () => { + const staleHead = new Error( + 'The merge request changed since it was loaded. Reload the merge request and try again.' + ); + Object.assign(staleHead, { data: { code: 'CONFLICT' } }); + providerMergeMutateMock.mockRejectedValueOnce(staleHead); + useMergePullRequestMutation(GITLAB_REF); + + await expect(lastCapturedOptions?.mutationFn?.(PROVIDER_MERGE_VARS)).rejects.toMatchObject({ + message: + 'The merge request changed since it was loaded. Reload the merge request and try again.', + }); + }); + + it('onSettled invalidates the provider caches (overview + checks + files)', async () => { + useMergePullRequestMutation(GITLAB_REF); + + await lastCapturedOptions?.onSettled?.(); + + expect(invalidateQueriesMock).toHaveBeenCalledWith({ + queryKey: ['providerReview', 'getPullRequest'], + }); + expect(invalidateQueriesMock).toHaveBeenCalledWith(['providerReview', 'listChecks']); + expect(invalidateQueriesMock).toHaveBeenCalledWith(['providerReview', 'listFiles']); + }); +}); + +describe('useMergePullRequestMutation (s6 bitbucket arm)', () => { + beforeEach(() => { + resetMocks(); + scopeOverride = { ref: BITBUCKET_REF, organizationId: 'org-9' }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('sends no squash/commitTitle (Bitbucket has only the merge commit) and pins the merge method', async () => { + providerMergeMutateMock.mockResolvedValueOnce({ done: true, replayed: false }); + useMergePullRequestMutation(BITBUCKET_REF); + + await lastCapturedOptions?.mutationFn?.(PROVIDER_MERGE_VARS); + const sent = providerMergeMutateMock.mock.calls[0]?.[0] as Record; + expect(sent).toEqual({ + ...BITBUCKET_IDENTITY, + expectedHeadSha: 'a'.repeat(40), + deleteBranch: true, + commitMessage: 'My message', + operationKey: 'hoisted-op-key', + }); + expect(hoistedKeys.getKey).toHaveBeenCalledWith( + '{"resource":["bitbucket","acme","widgets",77],"method":"merge","commitMessage":"My message","deleteBranch":true,"expectedHeadSha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' + ); + }); + + it('uses pull-request wording when the provider declines the merge', async () => { + providerMergeMutateMock.mockResolvedValueOnce({ done: false, replayed: false }); + useMergePullRequestMutation(BITBUCKET_REF); + + await expect(lastCapturedOptions?.mutationFn?.(PROVIDER_MERGE_VARS)).rejects.toMatchObject({ + message: 'The pull request was not merged. Check its state and try again.', + }); + }); +}); + +describe('useEnableAutoMergeMutation (s6 provider arms)', () => { + beforeEach(() => { + resetMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('gitlab: arms merge-when-pipeline-succeeds fenced on the head and announces success', async () => { + scopeOverride = { ref: GITLAB_REF, organizationId: 'org-9' }; + providerEnableAutoMergeMutateMock.mockResolvedValueOnce({ + supported: true, + reason: '', + done: true, + replayed: false, + }); + useEnableAutoMergeMutation(GITLAB_REF); + + await expect( + lastCapturedOptions?.mutationFn?.({ expectedHeadSha: 'a'.repeat(40) }) + ).resolves.toMatchObject({ supported: true }); + expect(providerEnableAutoMergeMutateMock).toHaveBeenCalledWith({ + ...GITLAB_IDENTITY, + expectedHeadSha: 'a'.repeat(40), + operationKey: 'hoisted-op-key', + }); + expect(hoistedKeys.getKey).toHaveBeenCalledWith( + '{"resource":["gitlab","https://gl.example.com","group/sub/app",12],"expectedHeadSha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' + ); + lastCapturedOptions?.onSuccess?.({ supported: true, reason: '', done: true, replayed: false }); + expect(announceMock.announceForA11y).toHaveBeenCalledWith('Auto-merge enabled'); + }); + + it('bitbucket: the supported:false answer resolves as a reason, not a success — no announcement', async () => { + scopeOverride = { ref: BITBUCKET_REF, organizationId: 'org-9' }; + const refusal = { + supported: false, + reason: 'Bitbucket Cloud does not expose auto-merge in its API', + done: false, + replayed: false, + }; + providerEnableAutoMergeMutateMock.mockResolvedValueOnce(refusal); + useEnableAutoMergeMutation(BITBUCKET_REF); + + await expect( + lastCapturedOptions?.mutationFn?.({ expectedHeadSha: 'a'.repeat(40) }) + ).resolves.toEqual(refusal); + lastCapturedOptions?.onSuccess?.(refusal); + expect(announceMock.announceForA11y).not.toHaveBeenCalled(); + }); +}); + describe('merge fingerprint (P1-A-08c changed-input)', () => { it('stays stable for a retry of the same merge and rotates when the method or message changes', () => { const original = prIntentFingerprint('merge', INPUT); diff --git a/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts b/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts index 6254b32542..160a33f270 100644 --- a/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts +++ b/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts @@ -1,4 +1,5 @@ -// S8 merge-side mutation hooks. Pattern mirrors the repo's existing +// S8 merge-side mutation hooks, s6: provider-aware through the +// `providerReview` seam. Pattern mirrors the repo's existing // mutation hooks (useSessionMutations, useSecurityAgentMutations): // - `onError` toasts the message // - `onSettled` invalidates the overview + the per-PR listChecks / @@ -6,26 +7,47 @@ // - keeps the mutation hook thin and lets the sheet / section handle // inline errors (toasts paint behind formSheets) // -// listChecks is keyed by `(owner, repo, ref)`. The head ref will change -// after a successful merge / update-branch, so we invalidate the +// listChecks is keyed by `(owner, repo, ref)` on GitHub. The head ref will +// change after a successful merge / update-branch, so we invalidate the // procedure PATH (not a single key) — every cached check list for this // PR is dropped and any mounted consumer re-fetches against the new // head. `listFiles` is per-page; we invalidate the full procedure too. +// The provider arms mirror this on `providerReview` (path filters). +// +// Provider arms (s6): `mergePullRequest` requires the `expectedHeadSha` +// fence on every provider — the server refuses a moved head with the +// explicit stale-head reason (CONFLICT) before any merge call, and the +// sheet keeps that reason inline without navigating away. The provider +// result `{done, replayed}` is normalized onto the GitHub result shape so +// the existing `assertMergeResult` gate keeps its "never celebrate a +// merge that did not happen" contract. Auto-merge is GitLab-only: +// Bitbucket Cloud has no auto-merge API, and the server answers +// `enableAutoMerge` with `{supported: false, reason}` so the UI shows the +// provider reason instead of failing. import { useMutation, useQueryClient } from '@tanstack/react-query'; import { prIntentFingerprint } from '@kilocode/app-shared/pr-review'; +import { providerPrTerm } from '@kilocode/app-shared/provider-review'; import { type inferRouterInputs, type MobileRouter } from '@kilocode/trpc/mobile'; import { i18n } from '@/i18n'; import { announceForA11y } from '@/lib/a11y/announce'; import { announcingToast } from '@/lib/a11y/announcing-toast'; import { trpcClient, useTRPC } from '@/lib/trpc'; +import { type ProviderPrScope, providerPrTriple } from '@/lib/pr-review/provider-pr-ref'; +import { + providerFingerprintInput, + providerWriteIdentity, + type ReviewWriteRef, + useReviewWriteScope, +} from '@/lib/pr-review/use-pr-review-mutations'; import { assertMergeResult, gateMergeResult, type MergePullRequestResult, } from '@/lib/pr-review/merge/merge-result-gate'; +import { MergeNotCompletedError } from '@/lib/pr-review/merge/merge-result-error'; import { useHoistedOperationKey } from '@/lib/operation-key'; import { isPrMutationRetryable, @@ -37,19 +59,64 @@ type PrRef = { owner: string; repo: string; number: number }; type RouterInputs = inferRouterInputs; type MergePullRequestInput = RouterInputs['githubPrReview']['mergePullRequest']; +type GithubAutoMergeInput = RouterInputs['githubPrReview']['enableAutoMerge']; +type GithubDisableAutoMergeInput = RouterInputs['githubPrReview']['disableAutoMerge']; + +/** + * The merge-sheet vars for a provider PR/MR. `method` is a UI concept: + * GitLab folds it into `squash`, Bitbucket Cloud has only the merge + * commit, and the fingerprint mirrors the server's `method` field. + */ +export type ProviderMergeVars = { + expectedHeadSha: string; + squash?: boolean; + deleteBranch?: boolean; + commitTitle?: string; + commitMessage?: string; +}; +export type MergeVars = MergePullRequestInput | ProviderMergeVars; + +/** The auto-merge vars for a provider MR (GitLab; Bitbucket answers unsupported). */ +type ProviderAutoMergeVars = { expectedHeadSha: string }; +export type EnableAutoMergeVars = GithubAutoMergeInput | ProviderAutoMergeVars; +export type DisableAutoMergeVars = GithubDisableAutoMergeInput | ProviderAutoMergeVars; + +/** + * The resolved auto-merge answer across arms: GitHub reports + * `{enabled, prNodeId}`, the provider seam reports `{supported, reason}` + * plus the ledger flags. Every field is optional so both arms land in the + * same `TData`; only `supported: false` (Bitbucket has no auto-merge API) + * is read back, to keep the success announcement off an unsupported answer. + */ +export type AutoMergeOutcome = { + enabled?: boolean; + prNodeId?: string; + supported?: boolean; + reason?: string; + done?: boolean; + replayed?: boolean; +}; -function usePrRefKeys(ref: PrRef) { +function useMergeKeys(scope: ProviderPrScope) { const trpc = useTRPC(); + if (scope.ref.platform === 'github') { + const triple = providerPrTriple(scope.ref); + return { + getPullRequest: trpc.githubPrReview.getPullRequest.queryKey(triple), + listChecksPath: trpc.githubPrReview.listChecks.pathFilter(), + listFilesPath: trpc.githubPrReview.listFiles.pathFilter(), + }; + } return { - getPullRequest: trpc.githubPrReview.getPullRequest.queryKey(ref), - listChecksPath: trpc.githubPrReview.listChecks.pathFilter(), - listFilesPath: trpc.githubPrReview.listFiles.pathFilter(), + getPullRequest: trpc.providerReview.getPullRequest.queryKey(providerWriteIdentity(scope)), + listChecksPath: trpc.providerReview.listChecks.pathFilter(), + listFilesPath: trpc.providerReview.listFiles.pathFilter(), }; } async function invalidatePrCaches( queryClient: ReturnType, - keys: ReturnType + keys: ReturnType ): Promise { await Promise.all([ queryClient.invalidateQueries({ queryKey: keys.getPullRequest }), @@ -58,9 +125,91 @@ async function invalidatePrCaches( ]); } -export function useMergePullRequestMutation(ref: PrRef) { +/** The hoisted operation-key getter a mutation arm stamps its fingerprint with. */ +type OperationKeyGetter = (fingerprint: string) => string; + +/** The GitHub merge arm — the pre-s6 call, unchanged. */ +// eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule +function githubMergeResult( + vars: MergePullRequestInput, + getKey: OperationKeyGetter +): Promise { + return trpcClient.githubPrReview.mergePullRequest.mutate({ + ...vars, + operationKey: getKey(prIntentFingerprint('merge', vars)), + }); +} + +/** + * The provider merge arm. One call per platform arm: a conditional spread + * inside a single literal would cross-multiply the identity union and hand + * the server gitlab-only fields on a bitbucket call. The layer reports no + * separate branch-delete outcome: the source-branch removal rides the merge + * call itself, so a committed merge is always the clean shape for the gate. + * A `done: false` answer is the same never-celebrate contract as GitHub's + * `merged: false`, but with provider wording: the default + * `MergeNotCompletedError` message names GitHub. + */ +async function providerMergeResult( + scope: ProviderPrScope, + vars: ProviderMergeVars, + getKey: OperationKeyGetter +): Promise { + const identity = providerWriteIdentity(scope); + const merged = + identity.platform === 'gitlab' + ? await trpcClient.providerReview.mergePullRequest.mutate({ + ...identity, + expectedHeadSha: vars.expectedHeadSha, + ...(vars.squash !== undefined ? { squash: vars.squash } : {}), + ...(vars.deleteBranch !== undefined ? { deleteBranch: vars.deleteBranch } : {}), + ...(vars.commitTitle !== undefined ? { commitTitle: vars.commitTitle } : {}), + ...(vars.commitMessage !== undefined ? { commitMessage: vars.commitMessage } : {}), + operationKey: getKey( + prIntentFingerprint( + 'merge', + providerFingerprintInput(identity, { + method: vars.squash ? 'squash' : 'merge', + commitTitle: vars.commitTitle, + commitMessage: vars.commitMessage, + deleteBranch: vars.deleteBranch, + expectedHeadSha: vars.expectedHeadSha, + }) + ) + ), + }) + : await trpcClient.providerReview.mergePullRequest.mutate({ + ...identity, + expectedHeadSha: vars.expectedHeadSha, + ...(vars.deleteBranch !== undefined ? { deleteBranch: vars.deleteBranch } : {}), + ...(vars.commitMessage !== undefined ? { commitMessage: vars.commitMessage } : {}), + operationKey: getKey( + prIntentFingerprint( + 'merge', + providerFingerprintInput(identity, { + method: 'merge', + commitMessage: vars.commitMessage, + deleteBranch: vars.deleteBranch, + expectedHeadSha: vars.expectedHeadSha, + }) + ) + ), + }); + if (!merged.done) { + throw new MergeNotCompletedError({ + sha: vars.expectedHeadSha, + message: i18n.t('prReview.operation.mergeResultNotCompleted', { + term: providerPrTerm(scope.ref.platform), + }), + }); + } + return { merged: true, sha: vars.expectedHeadSha, branchDeleted: false }; +} + +export function useMergePullRequestMutation(ref: ReviewWriteRef) { const queryClient = useQueryClient(); - const keys = usePrRefKeys(ref); + const scope = useReviewWriteScope(ref); + const keys = useMergeKeys(scope); const { getKey, rotateKey } = useHoistedOperationKey(); // P0-B-08: gate success on the authoritative `merged: true` result @@ -76,13 +225,13 @@ export function useMergePullRequestMutation(ref: PrRef) { // // P1-A-08c: the hoisted operation key rides the input so a same-key retry // reconciles against authoritative PR state before ever re-merging. - return useMutation({ + return useMutation({ mutationFn: async input => { try { - const result = await trpcClient.githubPrReview.mergePullRequest.mutate({ - ...input, - operationKey: getKey(prIntentFingerprint('merge', input)), - }); + const result = + scope.ref.platform === 'github' + ? await githubMergeResult(input as MergePullRequestInput, getKey) + : await providerMergeResult(scope, input as ProviderMergeVars, getKey); // Throws on `merged: false`; returns the gate on clean / partial. assertMergeResult(result); rotateKey(); @@ -119,7 +268,7 @@ export function useMergePullRequestMutation(ref: PrRef) { export function useUpdateBranchMutation(ref: PrRef) { const trpc = useTRPC(); const queryClient = useQueryClient(); - const keys = usePrRefKeys(ref); + const keys = useMergeKeys({ ref: { platform: 'github', ...ref }, organizationId: null }); return useMutation( trpc.githubPrReview.updateBranch.mutationOptions({ @@ -133,41 +282,104 @@ export function useUpdateBranchMutation(ref: PrRef) { ); } -export function useEnableAutoMergeMutation(ref: PrRef) { - const trpc = useTRPC(); +/** + * Arm auto-merge. GitHub keeps its exact pre-s6 path; GitLab arms + * merge-when-pipeline-succeeds fenced on the head; Bitbucket Cloud has no + * auto-merge API, so the server answers `{supported: false, reason}` — + * the sheet shows the reason instead of celebrating. + */ +export function useEnableAutoMergeMutation(ref: ReviewWriteRef) { const queryClient = useQueryClient(); - const keys = usePrRefKeys(ref); + const scope = useReviewWriteScope(ref); + const keys = useMergeKeys(scope); + const { getKey, rotateKey } = useHoistedOperationKey(); - return useMutation( - trpc.githubPrReview.enableAutoMerge.mutationOptions({ - onSuccess: () => { - // Bare success announcement beside the merge sheet's existing - // auto-merge success effect (haptic + refetch + dismiss). + return useMutation({ + mutationFn: async (vars: EnableAutoMergeVars) => { + if (scope.ref.platform === 'github') { + return trpcClient.githubPrReview.enableAutoMerge.mutate(vars as GithubAutoMergeInput); + } + try { + const result = await trpcClient.providerReview.enableAutoMerge.mutate({ + ...providerWriteIdentity(scope), + expectedHeadSha: (vars as ProviderAutoMergeVars).expectedHeadSha, + operationKey: getKey( + prIntentFingerprint( + 'enable_auto_merge', + providerFingerprintInput(scope.ref, { + expectedHeadSha: (vars as ProviderAutoMergeVars).expectedHeadSha, + }) + ) + ), + }); + rotateKey(); + return result; + } catch (error) { + if (!isPrMutationRetryable(error)) { + rotateKey(); + } + throw mapPrOperationError(error, 'merge'); + } + }, + onSuccess: (result: AutoMergeOutcome) => { + // Bare success announcement beside the merge sheet's existing + // auto-merge success effect (haptic + refetch + dismiss). A + // `supported: false` answer (Bitbucket) is not a success — the + // sheet renders the provider reason instead. + if (result.supported !== false) { announceForA11y(i18n.t('prReview.announce.autoMergeEnabled')); - }, - onError: (error: { message: string }) => { - announcingToast.error(error.message); - }, - onSettled: async () => { - await invalidatePrCaches(queryClient, keys); - }, - }) - ); + } + }, + onError: (error: { message: string }) => { + announcingToast.error(error.message); + }, + onSettled: async () => { + await invalidatePrCaches(queryClient, keys); + }, + }); } -export function useDisableAutoMergeMutation(ref: PrRef) { - const trpc = useTRPC(); +/** Cancel auto-merge. Same arms as `useEnableAutoMergeMutation`. */ +export function useDisableAutoMergeMutation(ref: ReviewWriteRef) { const queryClient = useQueryClient(); - const keys = usePrRefKeys(ref); + const scope = useReviewWriteScope(ref); + const keys = useMergeKeys(scope); + const { getKey, rotateKey } = useHoistedOperationKey(); - return useMutation( - trpc.githubPrReview.disableAutoMerge.mutationOptions({ - onError: (error: { message: string }) => { - announcingToast.error(error.message); - }, - onSettled: async () => { - await invalidatePrCaches(queryClient, keys); - }, - }) - ); + return useMutation({ + mutationFn: async (vars: DisableAutoMergeVars) => { + if (scope.ref.platform === 'github') { + return trpcClient.githubPrReview.disableAutoMerge.mutate( + vars as GithubDisableAutoMergeInput + ); + } + try { + const result = await trpcClient.providerReview.disableAutoMerge.mutate({ + ...providerWriteIdentity(scope), + expectedHeadSha: (vars as ProviderAutoMergeVars).expectedHeadSha, + operationKey: getKey( + prIntentFingerprint( + 'disable_auto_merge', + providerFingerprintInput(scope.ref, { + expectedHeadSha: (vars as ProviderAutoMergeVars).expectedHeadSha, + }) + ) + ), + }); + rotateKey(); + return result; + } catch (error) { + if (!isPrMutationRetryable(error)) { + rotateKey(); + } + throw mapPrOperationError(error, 'merge'); + } + }, + onError: (error: { message: string }) => { + announcingToast.error(error.message); + }, + onSettled: async () => { + await invalidatePrCaches(queryClient, keys); + }, + }); } diff --git a/apps/mobile/src/lib/pr-review/mutation-error-display.test.ts b/apps/mobile/src/lib/pr-review/mutation-error-display.test.ts index 41654145b6..fc39709b4a 100644 --- a/apps/mobile/src/lib/pr-review/mutation-error-display.test.ts +++ b/apps/mobile/src/lib/pr-review/mutation-error-display.test.ts @@ -46,11 +46,11 @@ describe('mutationErrorDisplay', () => { const ambiguous = new Error(PR_OPERATION_AMBIGUOUS_MESSAGE); const classification = classifyPrReviewMutationError(ambiguous); expect(classification).toEqual({ kind: 'retryable' }); - expect(mutationErrorDisplay('submit', classification, ambiguous)).toEqual({ + expect(mutationErrorDisplay('submit', classification, { rawError: ambiguous })).toEqual({ kind: 'retryable', message: PR_OPERATION_AMBIGUOUS_MESSAGE, }); - expect(mutationErrorDisplay('composer', classification, ambiguous)).toEqual({ + expect(mutationErrorDisplay('composer', classification, { rawError: ambiguous })).toEqual({ kind: 'retryable', message: PR_OPERATION_AMBIGUOUS_MESSAGE, }); @@ -68,14 +68,18 @@ describe('mutationErrorDisplay', () => { const persistenceFailed = new Error(PR_OPERATION_PERSISTENCE_FAILED_MESSAGE); const classification = classifyPrReviewMutationError(persistenceFailed); expect(classification).toEqual({ kind: 'retryable' }); - expect(mutationErrorDisplay('composer', classification, persistenceFailed)).toEqual({ - kind: 'bad-request', - message: PR_OPERATION_PERSISTENCE_FAILED_MESSAGE, - }); - expect(mutationErrorDisplay('submit', classification, persistenceFailed)).toEqual({ + expect( + mutationErrorDisplay('composer', classification, { rawError: persistenceFailed }) + ).toEqual({ kind: 'bad-request', message: PR_OPERATION_PERSISTENCE_FAILED_MESSAGE, }); + expect(mutationErrorDisplay('submit', classification, { rawError: persistenceFailed })).toEqual( + { + kind: 'bad-request', + message: PR_OPERATION_PERSISTENCE_FAILED_MESSAGE, + } + ); expect(mutationErrorDisplayFromError('submit', persistenceFailed)).toEqual({ kind: 'bad-request', message: PR_OPERATION_PERSISTENCE_FAILED_MESSAGE, @@ -98,6 +102,38 @@ describe('mutationErrorDisplay', () => { }); }); + it('words the submit bad-request copy after the connected provider when a term rides', () => { + // s6f: a rejected review submit on a GitLab merge request must never read + // "pull request". The provider arm passes the translated noun as `term`; + // GitHub (no term) keeps the exact pre-s6 copy asserted above. + const classification = classifyPrReviewMutationError( + makeError('BAD_REQUEST', 'You cannot perform the requested action') + ); + expect(mutationErrorDisplay('submit', classification, { term: 'merge request' })).toEqual({ + kind: 'bad-request', + message: + "This review can't be submitted as is. The merge request may have changed, or you can't review your own merge request.", + }); + // The convenience wrapper forwards the term too. + expect( + mutationErrorDisplayFromError( + 'submit', + makeError('BAD_REQUEST', 'You cannot perform the requested action'), + 'merge request' + ) + ).toEqual({ + kind: 'bad-request', + message: + "This review can't be submitted as is. The merge request may have changed, or you can't review your own merge request.", + }); + // The composer surface keeps its own copy even with a term. + expect(mutationErrorDisplay('composer', classification, { term: 'merge request' })).toEqual({ + kind: 'bad-request', + message: + "This comment can't be posted. The selected line may have changed, or the PR may have been updated.", + }); + }); + it('keeps reconnect copy fixed on both surfaces', () => { const classification = classifyPrReviewMutationError( makeError('PRECONDITION_FAILED', 'revoked') @@ -115,7 +151,7 @@ describe('mutationErrorDisplay', () => { it('keeps retryable kinds with surface-appropriate messages', () => { const networkError = new Error('Network request failed'); const classification = classifyPrReviewMutationError(networkError); - expect(mutationErrorDisplay('composer', classification, networkError)).toEqual({ + expect(mutationErrorDisplay('composer', classification, { rawError: networkError })).toEqual({ kind: 'retryable', message: 'Network request failed', }); @@ -123,7 +159,7 @@ describe('mutationErrorDisplay', () => { kind: 'retryable', message: 'Could not post comment.', }); - expect(mutationErrorDisplay('submit', classification, networkError)).toEqual({ + expect(mutationErrorDisplay('submit', classification, { rawError: networkError })).toEqual({ kind: 'retryable', message: 'Could not submit review. Check your connection and try again.', }); diff --git a/apps/mobile/src/lib/pr-review/mutation-error-display.ts b/apps/mobile/src/lib/pr-review/mutation-error-display.ts index 343f67269c..d94019fbb0 100644 --- a/apps/mobile/src/lib/pr-review/mutation-error-display.ts +++ b/apps/mobile/src/lib/pr-review/mutation-error-display.ts @@ -27,6 +27,31 @@ type MutationErrorDisplay = { type Classification = ReturnType; +type MutationErrorDisplayOptions = { + /** The raw thrown error, used for the retryable composer copy. */ + readonly rawError?: unknown; + /** + * The connected provider's noun, already translated (s6f): on the submit + * surface a bad-request then reads "The merge request may have changed, or + * you can't review your own merge request." instead of the hardcoded + * GitHub copy. Absent (GitHub), the exact pre-s6 copy stays. + */ + readonly term?: string; +}; + +/** + * The bad-request inline copy per surface. The submit surface words the + * rejection after the connected provider when a term rides (s6f). + */ +function badRequestMessage(surface: MutationErrorDisplaySurface, term?: string): string { + if (surface === 'composer') { + return i18n.t('prReview.mutationError.commentNotPosted'); + } + return term === undefined + ? i18n.t('prReview.mutationError.reviewNotSubmitted') + : i18n.t('prReview.mutationError.reviewNotSubmittedTerm', { term }); +} + /** * Maps a mutation classification (and optional raw error for retryable * composer copy) to the inline message the sheet should show. @@ -34,8 +59,9 @@ type Classification = ReturnType; export function mutationErrorDisplay( surface: MutationErrorDisplaySurface, classification: Classification, - rawError?: unknown + options?: MutationErrorDisplayOptions ): MutationErrorDisplay { + const rawError = options?.rawError; // The ambiguous outcome is NOT the generic retryable copy: the effect may // have committed, so the user must verify the PR. Verbatim on both surfaces. if (isPrOperationAmbiguous(rawError)) { @@ -51,13 +77,7 @@ export function mutationErrorDisplay( return { kind: 'forbidden', message: classification.message }; } if (classification.kind === 'bad-request') { - return { - kind: 'bad-request', - message: - surface === 'composer' - ? i18n.t('prReview.mutationError.commentNotPosted') - : i18n.t('prReview.mutationError.reviewNotSubmitted'), - }; + return { kind: 'bad-request', message: badRequestMessage(surface, options?.term) }; } if (classification.kind === 'reconnect') { return { kind: 'reconnect', message: i18n.t('prReview.connectionExpired') }; @@ -78,7 +98,11 @@ export function mutationErrorDisplay( */ export function mutationErrorDisplayFromError( surface: MutationErrorDisplaySurface, - error: unknown + error: unknown, + term?: string ): MutationErrorDisplay { - return mutationErrorDisplay(surface, classifyPrReviewMutationError(error), error); + return mutationErrorDisplay(surface, classifyPrReviewMutationError(error), { + rawError: error, + term, + }); } diff --git a/apps/mobile/src/lib/pr-review/pending-review-provider.mounted.test.tsx b/apps/mobile/src/lib/pr-review/pending-review-provider.mounted.test.tsx index 6aebc5936d..c26bae8172 100644 --- a/apps/mobile/src/lib/pr-review/pending-review-provider.mounted.test.tsx +++ b/apps/mobile/src/lib/pr-review/pending-review-provider.mounted.test.tsx @@ -9,6 +9,7 @@ import { pendingReviewDraftKey, type PendingReviewItem, PendingReviewProvider, + providerPendingReviewDraftKey, usePendingReview, } from './pending-review-provider'; @@ -401,3 +402,80 @@ describe('PendingReviewProvider persistence', () => { expect(draftsMock.flushDraft).toHaveBeenCalledWith('u1', 'pr-review:acme/kilo#42'); }); }); + +describe('providerPendingReviewDraftKey (s6, identity rule 17)', () => { + it('keeps the legacy GitHub bytes unchanged for a github ref (migration)', () => { + expect( + providerPendingReviewDraftKey({ + platform: 'github', + owner: 'octocat', + repo: 'hello', + number: 42, + }) + ).toBe(pendingReviewDraftKey('octocat', 'hello', 42)); + expect( + providerPendingReviewDraftKey({ + platform: 'github', + owner: 'Kilo-Org', + repo: 'Cloud', + number: 42, + }) + ).toBe('pr-review:kilo-org/cloud#42'); + }); + + it('separates a GitLab MR from a same-triple GitHub PR queue', () => { + const github = providerPendingReviewDraftKey({ + platform: 'github', + owner: 'group', + repo: 'app', + number: 7, + }); + const gitlab = providerPendingReviewDraftKey({ + platform: 'gitlab', + projectPath: 'group/app', + mrIid: 7, + instanceHint: 'https://gitlab.example.com', + }); + expect(gitlab).not.toBe(github); + expect(gitlab.startsWith(`${github}@`)).toBe(true); + }); + + it('separates one GitLab project reached on two instances', () => { + const atGitlab = providerPendingReviewDraftKey({ + platform: 'gitlab', + projectPath: 'group/app', + mrIid: 7, + instanceHint: 'https://gitlab.com', + }); + const atSelfManaged = providerPendingReviewDraftKey({ + platform: 'gitlab', + projectPath: 'group/app', + mrIid: 7, + instanceHint: 'https://gitlab.example.com', + }); + const withoutHint = providerPendingReviewDraftKey({ + platform: 'gitlab', + projectPath: 'group/app', + mrIid: 7, + }); + expect(atGitlab).not.toBe(atSelfManaged); + // An absent hint folds to '' — deliberately NOT equal to the SaaS host. + expect(withoutHint).not.toBe(atGitlab); + }); + + it('separates same-numbered Bitbucket PRs across workspaces', () => { + const one = providerPendingReviewDraftKey({ + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'app', + prId: 7, + }); + const other = providerPendingReviewDraftKey({ + platform: 'bitbucket', + workspace: 'other', + repoSlug: 'app', + prId: 7, + }); + expect(one).not.toBe(other); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/pending-review-provider.tsx b/apps/mobile/src/lib/pr-review/pending-review-provider.tsx index 6e61d5e033..c1920a02a4 100644 --- a/apps/mobile/src/lib/pr-review/pending-review-provider.tsx +++ b/apps/mobile/src/lib/pr-review/pending-review-provider.tsx @@ -10,7 +10,11 @@ import { useState, } from 'react'; import { z } from 'zod'; + +import { type ProviderPrRef, providerPrRefKey } from '@kilocode/app-shared/provider-review'; + import { clearDraft, loadDraft, prReviewDraftKey, saveDraft } from '@/lib/persist/drafts'; +import { providerPrTriple } from '@/lib/pr-review/provider-pr-ref'; import { useDraftFlushOnBackground } from '@/lib/persist/use-draft-flush'; // One queued inline comment in the pending review. The composer fills @@ -78,6 +82,23 @@ export function pendingReviewDraftKey(owner: string, repo: string, number: numbe return prReviewDraftKey(owner.toLowerCase(), repo.toLowerCase(), number); } +/** + * Draft entity key for one PR/MR on ANY provider (s6, identity rule 17). The + * GitHub arm returns the legacy `pendingReviewDraftKey` bytes unchanged, so a + * queue persisted before the provider surface keeps loading. A GitLab or + * Bitbucket ref folds the s1 collision-free `providerPrRefKey` into the same + * key, so a GitLab MR and a same-numbered GitHub PR — and one project reached + * on two GitLab instances — can never share a queue. + */ +export function providerPendingReviewDraftKey(ref: ProviderPrRef): string { + const triple = providerPrTriple(ref); + const legacy = pendingReviewDraftKey(triple.owner, triple.repo, triple.number); + if (ref.platform === 'github') { + return legacy; + } + return `${legacy}@${providerPrRefKey(ref)}`; +} + type PendingReviewContextValue = { items: PendingReviewItem[]; addComment: (item: PendingReviewItem) => void; diff --git a/apps/mobile/src/lib/pr-review/pr-link-paste.ts b/apps/mobile/src/lib/pr-review/pr-link-paste.ts index c9a693327e..092902cc70 100644 --- a/apps/mobile/src/lib/pr-review/pr-link-paste.ts +++ b/apps/mobile/src/lib/pr-review/pr-link-paste.ts @@ -1,5 +1,7 @@ +import { type ProviderPrRef } from '@kilocode/app-shared/provider-review'; + import { i18n } from '@/i18n'; -import { parseGitHubPrUrl } from '@/lib/github-pr-url'; +import { parseProviderPrUrl } from '@/lib/pr-review/provider-pr-url'; type PrLinkPasteDecision = | { kind: 'valid-pr-url'; text: string } @@ -8,20 +10,36 @@ type PrLinkPasteDecision = /** * Decide how a paste-button tap should treat clipboard contents. - * Trims first; empty → no insertion; valid PR URL → replace + navigate; - * anything else → replace + invalid toast at the call site. + * Trims first; empty → no insertion; a GitHub, GitLab (any host, including + * self-managed) or Bitbucket review URL → replace + navigate; anything else + * → replace + invalid toast at the call site. */ export function decidePrLinkPaste(clipboard: string | null | undefined): PrLinkPasteDecision { const text = (clipboard ?? '').trim(); if (text.length === 0) { return { kind: 'empty' }; } - if (parseGitHubPrUrl(text) !== null) { + if (parseProviderPrUrl(text) !== null) { return { kind: 'valid-pr-url', text }; } return { kind: 'non-url-text', text }; } +type PrLinkOpenDecision = { kind: 'open'; ref: ProviderPrRef } | { kind: 'invalid' }; + +/** + * Decide what the entry screen's Open action does with the field text: parse + * it through the one provider URL resolver and hand back the review identity + * it names, or the invalid-link state. Self-managed GitLab hosts parse here + * too — the server re-derives the authoritative instance (s2), so a + * mismatched host lands on the clear not-authorized state downstream, never + * a redirect. + */ +export function decidePrLinkOpen(raw: string): PrLinkOpenDecision { + const ref = parseProviderPrUrl(raw.trim()); + return ref ? { kind: 'open', ref } : { kind: 'invalid' }; +} + type PrLinkClearButtonInput = { /** Whether the uncontrolled PR-link field currently has any text. */ readonly hasInput: boolean; @@ -39,7 +57,7 @@ export function selectPrLinkClearButtonVisible(input: PrLinkClearButtonInput): b export function prLinkToastClipboardEmptyCopy(): string { return i18n.t('prReview.linkPasteClipboardEmpty'); } -/** Toast copy when paste or Open gets a non-PR link. */ +/** Toast copy when paste or Open gets a link no provider serves. */ export function prLinkToastInvalidCopy(): string { - return i18n.t('prReview.linkPasteNotAPullRequest'); + return i18n.t('prReview.linkPasteNotAReviewLink'); } diff --git a/apps/mobile/src/lib/pr-review/pr-review-connect-gate-view.ts b/apps/mobile/src/lib/pr-review/pr-review-connect-gate-view.ts new file mode 100644 index 0000000000..cd2bc50984 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/pr-review-connect-gate-view.ts @@ -0,0 +1,54 @@ +// Pure view selector for the provider-aware PR-review connect gate (s7). +// +// Extracted so every arm's branch order is unit-tested without mounting the +// gate. The gate keeps its simple priority ladder — a terminal explanation +// outranks a failed check, which outranks a pending check, which outranks +// Connect — and every non-happy outcome lands in a fixed set of +// header-bearing states. + +import { type ProviderPrPlatform } from '@kilocode/app-shared/provider-review'; + +export type PrReviewGateView = + | 'error' + | 'loading' + | 'org-only' + | 'connect' + | 'reconnect' + | 'children'; + +export type SelectPrReviewGateViewInput = { + /** Which provider's connection this mount checks. */ + readonly platform: ProviderPrPlatform; + readonly isError: boolean; + readonly isLoading: boolean; + readonly connected: boolean; + /** GitHub only: the user revoked the connection. */ + readonly revoked: boolean; + /** The selected organization, or null for the personal scope. */ + readonly organizationId: string | null; +}; + +/** + * Decide the gate's view. + * + * Bitbucket Cloud is organization-context only (s4), so a personal scope has + * no integration to connect and no retry that could succeed: the org-only + * explanation is the terminal state and outranks even a failed or pending + * check (the provider query is disabled there, and a disabled query reports + * pending forever). + */ +export function selectPrReviewGateView(args: SelectPrReviewGateViewInput): PrReviewGateView { + if (args.platform === 'bitbucket' && args.organizationId === null) { + return 'org-only'; + } + if (args.isError) { + return 'error'; + } + if (args.isLoading) { + return 'loading'; + } + if (!args.connected) { + return args.revoked ? 'reconnect' : 'connect'; + } + return 'children'; +} diff --git a/apps/mobile/src/lib/pr-review/provider-pr-queries.test.ts b/apps/mobile/src/lib/pr-review/provider-pr-queries.test.ts index 9784f8cd8a..4d2e7844fd 100644 --- a/apps/mobile/src/lib/pr-review/provider-pr-queries.test.ts +++ b/apps/mobile/src/lib/pr-review/provider-pr-queries.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- the namespace-selection tests and the pure normalizer tests share the same stubbed-trpc harness. */ import { describe, expect, it, vi } from 'vitest'; import { @@ -11,6 +12,7 @@ import { normalizeProviderOverview, normalizeProviderThreadsPage, normalizePrThreadsPages, + providerCapabilitiesIdentity, } from './provider-pr-queries'; import { githubPrRef, isProviderScopeReady, type ProviderPrScope } from './provider-pr-ref'; @@ -23,7 +25,7 @@ type Trpc = Parameters[0]; // Every read procedure both namespaces answer, stubbed by name so a builder // that picked the wrong namespace shows up as the wrong `procedure` tag. const GITHUB_PROCEDURES = ['getPullRequest', 'listChecks', 'listFiles', 'listReviewThreads']; -const PROVIDER_PROCEDURES = ['listDiscussions', 'getFileLines', 'getMergeState']; +const PROVIDER_PROCEDURES = ['listDiscussions', 'getFileLines', 'getMergeState', 'getCapabilities']; function makeTrpc() { const record = (name: string) => @@ -144,12 +146,37 @@ describe('namespace selection', () => { }); }); - it('has no merge-state procedure on GitHub (the overview carries the gate)', () => { + it('keeps the GitHub merge-state query disabled (the overview carries the gate)', () => { const { trpc } = makeTrpc(); - expect(buildPrMergeStateQueryOptions(trpc, githubScope)).toBeNull(); - expect(loose(buildPrMergeStateQueryOptions(trpc, gitlabScope)).procedure).toBe( - 'provider.getMergeState' - ); + // The GitHub arm registers a never-enabled query so the hook keeps one + // type; nothing leaves the app on that path. + const githubOptions = loose(buildPrMergeStateQueryOptions(trpc, githubScope)); + expect(githubOptions.procedure).toBe('provider.getMergeState'); + expect(githubOptions.enabled).toBe(false); + const gitlabOptions = loose(buildPrMergeStateQueryOptions(trpc, gitlabScope)); + expect(gitlabOptions.procedure).toBe('provider.getMergeState'); + expect(gitlabOptions.enabled).toBe(true); + }); + + it('builds the capability identity from the platform, never a repository ref', () => { + // The `getCapabilities` query itself stays at its call site (the option + // type only resolves inline); the seam owns the identity it reads under. + expect(providerCapabilitiesIdentity(gitlabScope)).toEqual({ platform: 'gitlab' }); + expect( + providerCapabilitiesIdentity({ + ref: { + platform: 'gitlab', + projectPath: 'g/r', + mrIid: 1, + instanceHint: 'gitlab.example.com', + }, + organizationId: 'org-2', + }) + ).toEqual({ platform: 'gitlab', instanceHint: 'gitlab.example.com', organizationId: 'org-2' }); + expect(providerCapabilitiesIdentity(bitbucketScope)).toEqual({ + platform: 'bitbucket', + organizationId: 'org-1', + }); }); it('paginates each provider on its own opaque cursor', () => { diff --git a/apps/mobile/src/lib/pr-review/provider-pr-queries.ts b/apps/mobile/src/lib/pr-review/provider-pr-queries.ts index b8bc0acf85..862fcddac8 100644 --- a/apps/mobile/src/lib/pr-review/provider-pr-queries.ts +++ b/apps/mobile/src/lib/pr-review/provider-pr-queries.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- the seam owns the one provider→GitHub read-model mapping and every query builder; splitting it would scatter the seam across callers. */ // The ONE data seam of the provider-aware PR review surface. // // Every read the review screens make goes through a builder here. Given a @@ -19,6 +20,7 @@ import { type ProviderPrChecksResult, type ProviderPrSummary, type ProviderPrThread, + type ProviderReviewCapabilities, } from '@kilocode/app-shared/provider-review'; import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; import { useMemo } from 'react'; @@ -368,16 +370,54 @@ export function buildPrFileLinesQueryOptions( /** * The provider merge gate. GitHub has no `getMergeState` procedure — its - * merge surface derives the gate from the overview DTO — so this returns - * null there and the caller keeps that path. + * merge surface derives the gate from the overview DTO — so its arm keeps + * the query disabled (a placeholder input registers it, nothing fetches). */ export function buildPrMergeStateQueryOptions(trpc: Trpc, scope: ProviderPrScope) { - if (scope.ref.platform === 'github') { - return null; + const isProviderArm = scope.ref.platform !== 'github'; + const identity = isProviderArm ? providerIdentity(scope) : null; + return trpc.providerReview.getMergeState.queryOptions( + identity ?? { platform: 'gitlab', projectPath: '', mrIid: 0 }, + { + enabled: isProviderArm && isProviderScopeReady(scope), + } + ); +} + +/** The identity `providerReview.getCapabilities` takes: no repository to pin. */ +type ProviderCapabilitiesInput = + | { platform: 'gitlab'; instanceHint?: string; organizationId?: string } + | { platform: 'bitbucket'; organizationId: string }; + +/** + * The identity the capability list is read under. The query itself stays at + * its call site (the merge screen's auto-merge arm, the review-submit event + * list) — the option type only resolves inline; a wrapper loses it under + * the linter's type inference. The GitHub arm keeps its query disabled and + * never touches `providerReview` over the network. + */ +export function providerCapabilitiesIdentity(scope: ProviderPrScope): ProviderCapabilitiesInput { + const ref = scope.ref; + if (ref.platform === 'bitbucket') { + return { platform: 'bitbucket', organizationId: scope.organizationId ?? '' }; } - return trpc.providerReview.getMergeState.queryOptions(providerIdentity(scope), { - enabled: isProviderScopeReady(scope), - }); + return { + platform: 'gitlab', + ...(ref.platform === 'gitlab' && ref.instanceHint ? { instanceHint: ref.instanceHint } : {}), + ...(scope.organizationId ? { organizationId: scope.organizationId } : {}), + }; +} + +/** + * The capability query's option type only resolves when the `queryOptions` + * call sits inline at its call site (a wrapper loses it under the checkers), + * so the screens read its answer back through this typed selector instead of + * member-accessing the raw, checker-opaque query data. + */ +export function selectProviderCapabilitiesData( + data: unknown +): ProviderReviewCapabilities | undefined { + return data as ProviderReviewCapabilities | undefined; } /** diff --git a/apps/mobile/src/lib/pr-review/provider-pr-url.test.ts b/apps/mobile/src/lib/pr-review/provider-pr-url.test.ts new file mode 100644 index 0000000000..2945582190 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/provider-pr-url.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; + +import { parseProviderPrUrl } from './provider-pr-url'; + +describe('parseProviderPrUrl — GitHub', () => { + it('parses a github.com pull request into the GitHub ref', () => { + expect(parseProviderPrUrl('https://github.com/octocat/hello-world/pull/42')).toEqual({ + platform: 'github', + owner: 'octocat', + repo: 'hello-world', + number: 42, + }); + }); + + it('tolerates trailing subpaths and query strings', () => { + expect(parseProviderPrUrl('https://github.com/o/r/pull/7/files?diff=split')).toEqual({ + platform: 'github', + owner: 'o', + repo: 'r', + number: 7, + }); + }); + + it('rejects a GitHub Enterprise host — the GitHub App serves github.com only', () => { + expect(parseProviderPrUrl('https://github.example.com/octocat/hello-world/pull/42')).toBeNull(); + }); +}); + +describe('parseProviderPrUrl — GitLab', () => { + it('parses the gitlab.com modern form with the origin as instanceHint', () => { + expect(parseProviderPrUrl('https://gitlab.com/group/sub/repo/-/merge_requests/123')).toEqual({ + platform: 'gitlab', + projectPath: 'group/sub/repo', + mrIid: 123, + instanceHint: 'https://gitlab.com', + }); + }); + + it('parses a self-managed host with a port, keeping the origin', () => { + expect( + parseProviderPrUrl('https://gitlab.example.com:8443/acme/api/-/merge_requests/9') + ).toEqual({ + platform: 'gitlab', + projectPath: 'acme/api', + mrIid: 9, + instanceHint: 'https://gitlab.example.com:8443', + }); + }); + + it('parses the legacy form without the /-/ separator', () => { + expect(parseProviderPrUrl('https://gitlab.example.com/group/project/merge_requests/5')).toEqual( + { + platform: 'gitlab', + projectPath: 'group/project', + mrIid: 5, + instanceHint: 'https://gitlab.example.com', + } + ); + }); + + it('tolerates trailing subpaths, queries and fragments', () => { + expect( + parseProviderPrUrl('https://gitlab.com/g/r/-/merge_requests/5/diffs?w=1#note_2') + ).toEqual({ + platform: 'gitlab', + projectPath: 'g/r', + mrIid: 5, + instanceHint: 'https://gitlab.com', + }); + }); + + it('lowercases the host and keeps the scheme', () => { + const ref = parseProviderPrUrl('https://GITLAB.Example.COM/G/Repo/-/merge_requests/1'); + expect(ref).toEqual({ + platform: 'gitlab', + projectPath: 'G/Repo', + mrIid: 1, + instanceHint: 'https://gitlab.example.com', + }); + }); + + it('accepts http on a self-managed instance', () => { + const ref = parseProviderPrUrl('http://git.lan/team/repo/-/merge_requests/2'); + expect(ref).not.toBeNull(); + expect(ref?.platform === 'gitlab' && ref.instanceHint).toBe('http://git.lan'); + }); + + it('rejects a single-segment project path — the server requires a group', () => { + expect(parseProviderPrUrl('https://gitlab.com/repo/merge_requests/1')).toBeNull(); + expect(parseProviderPrUrl('https://gitlab.com/repo/-/merge_requests/1')).toBeNull(); + }); + + it('rejects dot segments, empty segments, and non-numeric iids', () => { + expect(parseProviderPrUrl('https://gitlab.com/../repo/-/merge_requests/1')).toBeNull(); + expect(parseProviderPrUrl('https://gitlab.com/g//r/-/merge_requests/1')).toBeNull(); + expect(parseProviderPrUrl('https://gitlab.com/g/r/-/merge_requests/abc')).toBeNull(); + expect(parseProviderPrUrl('https://gitlab.com/g/r/-/merge_requests/0')).toBeNull(); + }); + + it('rejects a lone "-" inside the project path — only the separator may be one', () => { + expect(parseProviderPrUrl('https://gitlab.com/-/r/-/merge_requests/1')).toBeNull(); + expect(parseProviderPrUrl('https://gitlab.com/g/-/r/-/merge_requests/1')).toBeNull(); + expect(parseProviderPrUrl('https://gitlab.com/g/-/r/merge_requests/1')).toBeNull(); + }); + + it('never reads a github.com URL as a GitLab MR', () => { + expect(parseProviderPrUrl('https://github.com/g/r/-/merge_requests/1')).toBeNull(); + }); + + it('rejects a GitLab path without a merge_requests marker', () => { + expect(parseProviderPrUrl('https://gitlab.com/g/r/-/issues/5')).toBeNull(); + expect(parseProviderPrUrl('https://gitlab.com/g/r')).toBeNull(); + }); +}); + +describe('parseProviderPrUrl — Bitbucket', () => { + it('parses the plain pull request URL', () => { + expect(parseProviderPrUrl('https://bitbucket.org/acme/api/pull-requests/42')).toEqual({ + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'api', + prId: 42, + }); + }); + + it('parses the overview and other trailing-subpath variants', () => { + expect( + parseProviderPrUrl('https://bitbucket.org/acme/api/pull-requests/42/overview?tab=commits') + ).toEqual({ + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'api', + prId: 42, + }); + expect(parseProviderPrUrl('https://bitbucket.org/acme/api/pull-requests/42/diff')).toEqual({ + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'api', + prId: 42, + }); + }); + + it('rejects other hosts and malformed paths', () => { + expect( + parseProviderPrUrl('https://bitbucket.example.com/acme/api/pull-requests/42') + ).toBeNull(); + expect(parseProviderPrUrl('https://bitbucket.org/acme/pull-requests/42')).toBeNull(); + expect(parseProviderPrUrl('https://bitbucket.org/acme/api/pull-requests/x')).toBeNull(); + }); +}); + +describe('parseProviderPrUrl — garbage input', () => { + it('returns null for empty, whitespace, non-http and non-review URLs', () => { + expect(parseProviderPrUrl('')).toBeNull(); + expect(parseProviderPrUrl(' \n ')).toBeNull(); + expect(parseProviderPrUrl('not a url at all')).toBeNull(); + expect(parseProviderPrUrl('ftp://gitlab.com/g/r/-/merge_requests/1')).toBeNull(); + expect(parseProviderPrUrl('https://gitlab.com/o/r/pull/1')).toBeNull(); + expect(parseProviderPrUrl('https://app.kilo.ai/profile')).toBeNull(); + }); + + it('never throws on credential-bearing or malformed authorities', () => { + expect(parseProviderPrUrl('https://user:pass@gitlab.com/g/r/-/merge_requests/1')).toBeNull(); + expect(parseProviderPrUrl('https://')).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/provider-pr-url.ts b/apps/mobile/src/lib/pr-review/provider-pr-url.ts new file mode 100644 index 0000000000..3f1eb26413 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/provider-pr-url.ts @@ -0,0 +1,172 @@ +// One URL resolver for every route into a review: a pasted link, a shared +// link, a session badge or a recents entry all parse through here, so the +// three providers' URL shapes are decided in exactly one place. +// +// The result is the s1 `ProviderPrRef` identity: platform, the provider's own +// repository path, and the number. A GitLab ref carries the pasted URL's +// origin as `instanceHint` — identity/recents only; the server re-derives the +// authoritative instance (s2), so a mismatched host yields the clear +// not-authorized state, never a redirect. +// +// `parseGitHubPrUrl` (`@/lib/github-pr-url`) stays for compat and owns the +// GitHub arm; this resolver delegates to it rather than re-spelling the +// pattern. + +import { type ProviderPrRef } from '@kilocode/app-shared/provider-review'; + +import { parseGitHubPrUrl } from '@/lib/github-pr-url'; + +const MERGE_REQUESTS_SEGMENT = 'merge_requests'; +const PULL_REQUESTS_SEGMENT = 'pull-requests'; +const BITBUCKET_HOST = 'bitbucket.org'; +/** Hosts that can never be a GitLab instance, so a GitHub URL never leaks into the GitLab arm. */ +const NON_GITLAB_HOSTS = new Set(['github.com', 'www.github.com']); + +/** A path segment GitLab accepts in a project path: the server's own shape. */ +const GITLAB_SEGMENT_PATTERN = /^[A-Za-z0-9_.-]+$/; +/** A Bitbucket workspace/repository slug: the server's own shape. */ +const BITBUCKET_SLUG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; + +type SplitUrl = { + /** Lowercased scheme, e.g. `https`. */ + scheme: string; + /** Lowercased authority (host, plus port when non-default). */ + host: string; + /** Path segments, query and fragment dropped; empty segments preserved. */ + segments: string[]; +}; + +/** + * Split `http(s)://host[:port]/a/b` into its parts without `URL` — Hermes + * does not guarantee full WHATWG parsing. Returns `null` for any other + * scheme, a missing host, or credentials in the authority. + */ +function splitHttpUrl(href: string): SplitUrl | null { + const match = /^(https?):\/\/([^/?#@]+)([^?#]*)/i.exec(href); + if (!match) { + return null; + } + const scheme = (match[1] ?? '').toLowerCase(); + const host = (match[2] ?? '').toLowerCase(); + const path = match[3] ?? ''; + if (host.length === 0) { + return null; + } + if (path.length === 0 || path === '/') { + return { scheme, host, segments: [] }; + } + if (!path.startsWith('/')) { + return null; + } + return { scheme, host, segments: path.slice(1).split('/') }; +} + +/** Positive integer from a path segment, or null. No trailing junk. */ +function parseNumberSegment(segment: string | undefined): number | null { + if (segment === undefined || !/^\d+$/.test(segment)) { + return null; + } + const number = Number.parseInt(segment, 10); + return Number.isSafeInteger(number) && number > 0 ? number : null; +} + +/** + * `https:///...//-/merge_requests/` and the legacy + * `https:////merge_requests/`, on ANY host — gitlab.com + * and self-managed instances alike. The project path is the FULL nested + * path; trailing subpaths (`/diffs`, `/review-notes`) are tolerated. + */ +function parseGitLabMrUrl(url: SplitUrl): ProviderPrRef | null { + if (NON_GITLAB_HOSTS.has(url.host)) { + return null; + } + const { segments } = url; + for (let i = 1; i < segments.length; i += 1) { + if (segments[i] === MERGE_REQUESTS_SEGMENT) { + const ref = gitLabRefAtMarker(url, segments, i); + if (ref) { + return ref; + } + } + } + return null; +} + +/** The ref named by `merge_requests/` at `segments[markerAt]`, or null. */ +function gitLabRefAtMarker( + url: SplitUrl, + segments: string[], + markerAt: number +): ProviderPrRef | null { + const modern = segments[markerAt - 1] === '-'; + const pathEnd = modern ? markerAt - 1 : markerAt; + const mrIid = parseNumberSegment(segments[markerAt + 1]); + if (mrIid === null || pathEnd < 2) { + return null; + } + const projectPath = segments.slice(0, pathEnd); + if (!projectPath.every(segment => isGitLabPathSegment(segment))) { + return null; + } + return { + platform: 'gitlab', + projectPath: projectPath.join('/'), + mrIid, + instanceHint: `${url.scheme}://${url.host}`, + }; +} + +function isGitLabPathSegment(segment: string): boolean { + // '.', '..' and the bare '-' separator are shapes the server never names a + // project with; accepting them would let `/repo/-/merge_requests/1` read as + // the project `repo/-`. + return segment !== '.' && segment !== '..' && segment !== '-' && GITLAB_SEGMENT_PATTERN.test(segment); +} + +/** + * `https://bitbucket.org///pull-requests/`, including the + * `/overview` and other trailing-subpath variants. Bitbucket Cloud only — + * its host is fixed. + */ +function parseBitbucketPrUrl(url: SplitUrl): ProviderPrRef | null { + if (url.host !== BITBUCKET_HOST) { + return null; + } + const [workspace, repoSlug, marker, idSegment] = url.segments; + if (marker !== PULL_REQUESTS_SEGMENT || workspace === undefined || repoSlug === undefined) { + return null; + } + const prId = parseNumberSegment(idSegment); + if ( + prId === null || + !BITBUCKET_SLUG_PATTERN.test(workspace) || + !BITBUCKET_SLUG_PATTERN.test(repoSlug) + ) { + return null; + } + return { platform: 'bitbucket', workspace, repoSlug, prId }; +} + +/** + * Parse a pasted/shared PR/MR URL into the provider identity it names. + * + * GitHub (`github.com` only — the GitHub App serves no Enterprise host), + * GitLab merge requests on ANY host including self-managed instances, and + * Bitbucket Cloud pull requests. Returns `null` for anything else, so the + * caller can show its invalid-link state. + */ +export function parseProviderPrUrl(href: string): ProviderPrRef | null { + const trimmed = href.trim(); + if (trimmed.length === 0) { + return null; + } + const github = parseGitHubPrUrl(trimmed); + if (github) { + return { platform: 'github', ...github }; + } + const url = splitHttpUrl(trimmed); + if (!url) { + return null; + } + return parseGitLabMrUrl(url) ?? parseBitbucketPrUrl(url); +} diff --git a/apps/mobile/src/lib/pr-review/recent-pr-row-state.test.ts b/apps/mobile/src/lib/pr-review/recent-pr-row-state.test.ts new file mode 100644 index 0000000000..39128d5e4b --- /dev/null +++ b/apps/mobile/src/lib/pr-review/recent-pr-row-state.test.ts @@ -0,0 +1,91 @@ +import '@/i18n'; + +import { describe, expect, it } from 'vitest'; + +import { selectRecentPrRowState } from './recent-pr-row-state'; +import { type RecentPr } from '@/lib/pr-review/recent-prs'; + +function makeRecent(overrides: Partial = {}): RecentPr { + return { + owner: 'octocat', + repo: 'hello-world', + number: 42, + title: 'Hello PR', + lastOpenedAt: 1_700_000_000_000, + ...overrides, + }; +} + +describe('selectRecentPrRowState', () => { + it('uses the title as primary and the identity as secondary', () => { + expect(selectRecentPrRowState(makeRecent({ title: 'Hello PR' }))).toEqual({ + primary: 'Hello PR', + secondary: 'octocat/hello-world#42', + failed: false, + provider: 'GitHub', + }); + }); + + it('drops the secondary line when the title is empty (no duplicate)', () => { + expect(selectRecentPrRowState(makeRecent({ title: '' }))).toEqual({ + primary: 'octocat/hello-world#42', + secondary: null, + failed: false, + provider: 'GitHub', + }); + }); + + it('marks a failed entry', () => { + expect(selectRecentPrRowState(makeRecent({ lastResult: 'failed' }))).toEqual({ + primary: 'Hello PR', + secondary: 'octocat/hello-world#42', + failed: true, + provider: 'GitHub', + }); + }); + + it('treats a missing lastResult as ok', () => { + expect(selectRecentPrRowState(makeRecent()).failed).toBe(false); + }); + + it('labels a GitLab entry with the instance path and the ! separator', () => { + expect( + selectRecentPrRowState( + makeRecent({ owner: 'group/sub', repo: 'api', number: 12, platform: 'gitlab' }) + ) + ).toEqual({ + primary: 'Hello PR', + secondary: 'group/sub/api!12', + failed: false, + provider: 'GitLab', + }); + }); + + it('labels a Bitbucket entry with the workspace path', () => { + expect( + selectRecentPrRowState( + makeRecent({ owner: 'acme', repo: 'api', number: 7, platform: 'bitbucket' }) + ) + ).toEqual({ + primary: 'Hello PR', + secondary: 'acme/api#7', + failed: false, + provider: 'Bitbucket', + }); + }); + + it('renders same-named repos across providers as distinct labelled rows', () => { + const rows = ( + [ + {}, + { platform: 'gitlab' as const }, + { platform: 'bitbucket' as const }, + ] satisfies Partial[] + ).map(overrides => + selectRecentPrRowState(makeRecent({ owner: 'acme', repo: 'api', number: 7, ...overrides })) + ); + + expect(rows.map(row => row.provider)).toEqual(['GitHub', 'GitLab', 'Bitbucket']); + expect(rows.map(row => row.secondary)).toEqual(['acme/api#7', 'acme/api!7', 'acme/api#7']); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/recent-pr-row-state.ts b/apps/mobile/src/lib/pr-review/recent-pr-row-state.ts new file mode 100644 index 0000000000..7d364165d2 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/recent-pr-row-state.ts @@ -0,0 +1,47 @@ +// Pure row-state selection for a recents entry. Extracted so the +// duplicated fallback line (title-less entries printing the identity +// twice) and the failed-entry marker are unit-tested. +// +// Multi-provider identity (s7): the entry's platform decides the identity +// separator (GitLab writes `group/repo!12`, the others `owner/repo#7`) and +// the provider label the row renders, so same-named repositories on +// different providers read as separate rows. + +import { type ProviderPrPlatform } from '@kilocode/app-shared/provider-review'; + +import { i18n } from '@/i18n'; +import { type RecentPr } from '@/lib/pr-review/recent-prs'; + +export type RecentPrRowState = { + /** Title when non-empty, else the identity line. */ + primary: string; + /** The identity line, or null when it would repeat `primary`. */ + secondary: string | null; + /** True when the entry's last load attempt failed. */ + failed: boolean; + /** The provider's own name, rendered as the row's badge. */ + provider: string; +}; + +export function selectRecentPrRowState(entry: RecentPr): RecentPrRowState { + const platform: ProviderPrPlatform = entry.platform ?? 'github'; + const separator = platform === 'gitlab' ? '!' : '#'; + const identity = `${entry.owner}/${entry.repo}${separator}${entry.number}`; + const primary = entry.title.length > 0 ? entry.title : identity; + return { + primary, + secondary: primary === identity ? null : identity, + failed: entry.lastResult === 'failed', + provider: recentPrProviderLabel(platform), + }; +} + +function recentPrProviderLabel(platform: ProviderPrPlatform): string { + if (platform === 'gitlab') { + return i18n.t('common.gitlab'); + } + if (platform === 'bitbucket') { + return i18n.t('agentChat.repoPicker.platformBitbucket'); + } + return i18n.t('common.github'); +} diff --git a/apps/mobile/src/lib/pr-review/recent-prs.ts b/apps/mobile/src/lib/pr-review/recent-prs.ts index f9fc29955b..b82f31fd0f 100644 --- a/apps/mobile/src/lib/pr-review/recent-prs.ts +++ b/apps/mobile/src/lib/pr-review/recent-prs.ts @@ -1,6 +1,12 @@ import * as SecureStore from 'expo-secure-store'; import { z } from 'zod'; +import { + gitlabInstanceOrigin, + type ProviderPrPlatform, + type ProviderPrRef, +} from '@kilocode/app-shared/provider-review'; + import { deleteAccountMetadata, writeAccountMetadata } from '@/lib/auth/account-metadata-write'; import { PR_REVIEW_RECENTS_KEY } from '@/lib/storage-keys'; @@ -16,6 +22,19 @@ export type RecentPr = { * missing field on a legacy stored entry) marks a successful load. */ lastResult?: 'ok' | 'failed'; + /** + * The provider this entry belongs to. Entries stored before the + * multi-provider recents (s7) carry no platform and read as `'github'` — + * they were only ever written by the GitHub backfill. + */ + platform?: ProviderPrPlatform; + /** + * GitLab only: the origin of the instance the entry was opened on. + * Identity/recents only — the server re-derives the authoritative + * instance (s2), so a stale hint never redirects, it only keeps one row + * per instance. + */ + instanceHint?: string; }; const recentPrSchema = z.object({ @@ -25,18 +44,55 @@ const recentPrSchema = z.object({ title: z.string(), lastOpenedAt: z.number(), lastResult: z.enum(['ok', 'failed']).optional(), + platform: z.enum(['github', 'gitlab', 'bitbucket']).optional(), + instanceHint: z.string().optional(), }); const RECENT_PR_LIMIT = 10; -type RecentPrRef = { +/** The identity one recents entry is stored, keyed, removed and marked under. */ +export type RecentPrRef = { owner: string; repo: string; number: number; + platform?: ProviderPrPlatform; + instanceHint?: string; }; -function recentPrKey(item: RecentPrRef): string { - return `${item.owner.toLowerCase()}/${item.repo.toLowerCase()}#${item.number}`; +/** + * The collision-free recents key: platform, the GitLab instance origin, and + * the lowercased repository path plus number. The bare `owner/repo#number` + * triple collided across providers — the same-named repository on GitHub, + * GitLab and Bitbucket is three separate rows, and one GitLab project on two + * instances is two rows. GitHub paths keep their existing case-fold so a + * differently cased paste of the same PR still dedupes. + */ +export function recentPrKey(item: RecentPrRef): string { + const platform = item.platform ?? 'github'; + const instance = platform === 'gitlab' ? gitlabInstanceOrigin(item.instanceHint) : ''; + return `${platform}|${instance}|${item.owner.toLowerCase()}/${item.repo.toLowerCase()}#${item.number}`; +} + +/** + * The provider ref a recents row navigates with. The entry's writers store + * the `owner`/`repo`/`number` triple exactly as `providerPrTriple` splits + * the provider path at its last separator, so folding it back with the + * platform (legacy entries: GitHub) restores the ref losslessly. + */ +export function providerRefFromRecentPr(entry: RecentPrRef): ProviderPrRef { + const platform: ProviderPrPlatform = entry.platform ?? 'github'; + if (platform === 'gitlab') { + return { + platform: 'gitlab', + projectPath: `${entry.owner}/${entry.repo}`, + mrIid: entry.number, + ...(entry.instanceHint ? { instanceHint: entry.instanceHint } : {}), + }; + } + if (platform === 'bitbucket') { + return { platform: 'bitbucket', workspace: entry.owner, repoSlug: entry.repo, prId: entry.number }; + } + return { platform: 'github', owner: entry.owner, repo: entry.repo, number: entry.number }; } function parseRecents(raw: string | null): RecentPr[] { @@ -54,7 +110,9 @@ function parseRecents(raw: string | null): RecentPr[] { return []; } // Legacy entries predate `lastResult`; read a missing field as 'ok' - // so an installed app's existing recents survive the upgrade. + // so an installed app's existing recents survive the upgrade. A + // missing `platform` (s7) reads as GitHub, which is the only provider + // the pre-s7 writers could have stored. return [ { ...result.data, lastResult: result.data.lastResult === 'failed' ? 'failed' : 'ok' }, ]; diff --git a/apps/mobile/src/lib/pr-review/use-check-provider-connection.ts b/apps/mobile/src/lib/pr-review/use-check-provider-connection.ts new file mode 100644 index 0000000000..abf336f341 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/use-check-provider-connection.ts @@ -0,0 +1,66 @@ +// Provider-aware connection check for the PR-review connect gate. +// +// `useCheckGitHubConnection` stays the GitHub arm (it refetches the GitHub +// App user authorization and invalidates `githubPrReview`). GitLab and +// Bitbucket have no per-user GitHub authorization to check — their +// connectivity is the integration status the s4 endpoints expose +// (`getGitLabStatus` personal + org, `getBitbucketReadiness` org), so this +// hook force-refetches that status and invalidates `providerReview` once the +// provider answers connected. + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner-native'; + +import { useTRPC } from '@/lib/trpc'; + +export type ProviderConnectionPlatform = 'gitlab' | 'bitbucket'; + +export type CheckProviderConnectionInput = { + platform: ProviderConnectionPlatform; + /** The selected organization, or null for the personal scope. */ + organizationId: string | null; +}; + +/** + * Force-fresh provider connection check. Bitbucket Cloud is + * organization-context only, so a personal scope has nothing to check and + * the mutation reports not-connected without a call. + */ +export function useCheckProviderConnection() { + const queryClient = useQueryClient(); + const trpc = useTRPC(); + + return useMutation({ + mutationFn: async (input: CheckProviderConnectionInput) => { + if (input.platform === 'bitbucket' && !input.organizationId) { + return { connected: false }; + } + // The two providers answer with different status shapes, so each arm + // fetches its own options — a union of the two queryOptions types is + // not assignable to a single fetchQuery call. + const status = + input.platform === 'gitlab' + ? await queryClient.fetchQuery({ + ...(input.organizationId + ? trpc.organizations.reviewAgent.getGitLabStatus.queryOptions({ + organizationId: input.organizationId, + }) + : trpc.personalReviewAgent.getGitLabStatus.queryOptions()), + staleTime: 0, + }) + : await queryClient.fetchQuery({ + ...trpc.organizations.reviewAgent.getBitbucketReadiness.queryOptions({ + organizationId: input.organizationId ?? '', + }), + staleTime: 0, + }); + if (status.connected) { + await queryClient.invalidateQueries(trpc.providerReview.pathFilter()); + } + return status; + }, + onError: error => { + toast.error(error.message); + }, + }); +} 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 dd3d3d4854..7d77c13ff3 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 @@ -1,19 +1,29 @@ -// P1-A-08c wiring tests for `useCreateReviewCommentMutation` and +/* eslint-disable max-lines -- the comment and review-submit suites share one mock harness for the review seam */ +// P1-A-08c + s6 wiring tests for `useCreateReviewCommentMutation` and // `useSubmitReviewMutation`. // // The sheet / composer / pending-review surfaces own the inline error // rendering; these tests assert the HOOK WIRING: each `mutationFn` -// delegates to the matching `trpcClient.githubPrReview..mutate`, -// the hoisted operation key is merged into the input, and the key rotation +// delegates to the matching `trpcClient...mutate`, the +// hoisted operation key is merged into the input, and the key rotation // policy (real `isPrMutationRetryable` + `mapPrOperationError`) runs inside // `mutationFn`. Only `useHoistedOperationKey` is mocked (it holds React ref // state that needs a mounted renderer, covered by // `operation-key.mounted.test.tsx`). +// +// s6: the GitHub arms stay byte-identical (same procedures, same inputs, +// same pinned fingerprints). The provider arms route the same intents +// through `providerReview.*` with the s1 provider identity, and the +// 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). import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type * as OperationKeyModule from '@/lib/operation-key'; import { prIntentFingerprint } from '@kilocode/app-shared/pr-review'; +import type * as ProviderPrRefModule from '@/lib/pr-review/provider-pr-ref'; +import { type ProviderPrRef, type ProviderPrTriple } from '@/lib/pr-review/provider-pr-ref'; import { useCreateReviewCommentMutation, useSubmitReviewMutation } from './use-pr-review-mutations'; const hoistedKeys = vi.hoisted(() => ({ @@ -30,6 +40,21 @@ vi.mock('@/lib/operation-key', async importOriginal => { return { ...actual, useHoistedOperationKey: () => hoistedKeys }; }); +// The hooks read the live provider scope from context. These tests call the +// hooks as plain functions (no renderer), so the context hook is replaced by +// a settable override: null keeps the GitHub fallback the pre-s6 surface +// used; a provider scope drives the provider arms. +let scopeOverride: { ref: ProviderPrRef; organizationId: string | null } | null = null; + +vi.mock('@/lib/pr-review/provider-pr-ref', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useProviderPrScope: (fallback: ProviderPrTriple) => + scopeOverride ?? { ref: { platform: 'github', ...fallback }, organizationId: null }, + }; +}); + type MutationOptions = { mutationFn?: (vars: unknown) => Promise; onError?: (error: unknown) => void; @@ -39,6 +64,8 @@ type MutationOptions = { let lastCapturedOptions: MutationOptions | null = null; const createCommentMutateMock = vi.fn(); const submitReviewMutateMock = vi.fn(); +const providerAddCommentMutateMock = vi.fn(); +const providerSubmitReviewMutateMock = vi.fn(); const invalidateQueriesMock = vi.fn(); const toastErrorMock = vi.fn(); @@ -60,6 +87,10 @@ vi.mock('@/lib/trpc', () => ({ getPullRequest: { queryKey: () => ['githubPrReview', 'getPullRequest'] }, listReviewThreads: { pathFilter: () => ['githubPrReview', 'listReviewThreads'] }, }, + providerReview: { + getPullRequest: { queryKey: () => ['providerReview', 'getPullRequest'] }, + listDiscussions: { pathFilter: () => ['providerReview', 'listDiscussions'] }, + }, }), trpcClient: { githubPrReview: { @@ -68,6 +99,12 @@ vi.mock('@/lib/trpc', () => ({ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule submitReview: { mutate: (vars: unknown) => submitReviewMutateMock(vars) }, }, + providerReview: { + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + addComment: { mutate: (vars: unknown) => providerAddCommentMutateMock(vars) }, + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + submitReview: { mutate: (vars: unknown) => providerSubmitReviewMutateMock(vars) }, + }, }, })); @@ -89,6 +126,36 @@ vi.mock('react-native', () => ({ const REF = { owner: 'octocat', repo: 'hello', number: 1 }; +const GITLAB_REF: ProviderPrRef = { + platform: 'gitlab', + projectPath: 'group/sub/app', + mrIid: 12, + instanceHint: 'https://gl.example.com', +}; + +const BITBUCKET_REF: ProviderPrRef = { + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'widgets', + prId: 77, +}; + +const GITLAB_IDENTITY = { + platform: 'gitlab', + projectPath: 'group/sub/app', + mrIid: 12, + instanceHint: 'https://gl.example.com', + organizationId: 'org-9', +}; + +const BITBUCKET_IDENTITY = { + platform: 'bitbucket', + workspace: 'acme', + repoSlug: 'widgets', + prId: 77, + organizationId: 'org-9', +}; + const COMMENT_INPUT = { owner: 'octocat', repo: 'hello', @@ -110,14 +177,22 @@ const REVIEW_INPUT = { comments: [{ path: 'README.md', line: 3, side: 'RIGHT' as const, body: 'nit' }], }; +function resetMocks() { + lastCapturedOptions = null; + scopeOverride = null; + createCommentMutateMock.mockReset(); + submitReviewMutateMock.mockReset(); + providerAddCommentMutateMock.mockReset(); + providerSubmitReviewMutateMock.mockReset(); + invalidateQueriesMock.mockReset(); + toastErrorMock.mockReset(); + hoistedKeys.getKey.mockClear(); + hoistedKeys.rotateKey.mockClear(); +} + describe('useCreateReviewCommentMutation (P1-A-08c wiring)', () => { beforeEach(() => { - lastCapturedOptions = null; - createCommentMutateMock.mockReset(); - invalidateQueriesMock.mockReset(); - toastErrorMock.mockReset(); - hoistedKeys.getKey.mockClear(); - hoistedKeys.rotateKey.mockClear(); + resetMocks(); }); afterEach(() => { @@ -216,14 +291,135 @@ describe('useCreateReviewCommentMutation (P1-A-08c wiring)', () => { }); }); +describe('useCreateReviewCommentMutation (s6 gitlab arm)', () => { + beforeEach(() => { + resetMocks(); + scopeOverride = { ref: GITLAB_REF, organizationId: 'org-9' }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('routes the body through providerReview.addComment with the full identity', async () => { + providerAddCommentMutateMock.mockResolvedValueOnce({ done: true, replayed: false }); + useCreateReviewCommentMutation(GITLAB_REF); + + await expect(lastCapturedOptions?.mutationFn?.({ body: 'inline nit' })).resolves.toEqual({ + done: true, + replayed: false, + }); + expect(createCommentMutateMock).not.toHaveBeenCalled(); + expect(providerAddCommentMutateMock).toHaveBeenCalledWith({ + ...GITLAB_IDENTITY, + body: 'inline nit', + operationKey: 'hoisted-op-key', + }); + }); + + it('folds the GitLab instance into the fingerprint (identity rule 17)', async () => { + providerAddCommentMutateMock.mockResolvedValueOnce({ done: true, replayed: false }); + useCreateReviewCommentMutation(GITLAB_REF); + + await lastCapturedOptions?.mutationFn?.({ body: 'inline nit' }); + + // Pinned bytes mirroring the server's gitlabFingerprintInput: platform + + // instanceHint + projectPath + number (as `number`) + body. + expect(hoistedKeys.getKey).toHaveBeenCalledWith( + '{"resource":["gitlab","https://gl.example.com","group/sub/app",12],"body":"inline nit"}' + ); + }); + + 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', { + platform: 'gitlab', + projectPath: 'group/sub/app', + instanceHint: 'https://gl.example.com', + number: 12, + body: 'inline nit', + }); + const onInstanceB = prIntentFingerprint('create_review_comment', { + platform: 'gitlab', + projectPath: 'group/sub/app', + instanceHint: 'https://gl.other.example', + number: 12, + body: 'inline nit', + }); + const onGitHub = prIntentFingerprint('create_review_comment', COMMENT_INPUT); + expect(onInstanceA).not.toBe(onInstanceB); + expect(onInstanceA).not.toBe(onGitHub); + + useCreateReviewCommentMutation({ ...GITLAB_REF, instanceHint: 'https://gl.other.example' }); + await lastCapturedOptions?.mutationFn?.({ body: 'inline nit' }); + expect(hoistedKeys.getKey).toHaveBeenLastCalledWith( + '{"resource":["gitlab","https://gl.other.example","group/sub/app",12],"body":"inline nit"}' + ); + }); + + it('keeps the key on an in-progress CONFLICT and rotates on a non-retryable refusal', async () => { + providerAddCommentMutateMock.mockRejectedValueOnce(new Error('operation_in_progress')); + useCreateReviewCommentMutation(GITLAB_REF); + await expect(lastCapturedOptions?.mutationFn?.({ body: 'x' })).rejects.toBeInstanceOf(Error); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + + const refused = new Error('Merge request is locked'); + Object.assign(refused, { data: { code: 'BAD_REQUEST' } }); + providerAddCommentMutateMock.mockRejectedValueOnce(refused); + await expect(lastCapturedOptions?.mutationFn?.({ body: 'x' })).rejects.toMatchObject({ + message: 'Merge request is locked', + }); + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('onSettled invalidates the provider caches (overview + discussions)', async () => { + useCreateReviewCommentMutation(GITLAB_REF); + + await lastCapturedOptions?.onSettled?.(); + + expect(invalidateQueriesMock).toHaveBeenCalledWith({ + queryKey: ['providerReview', 'getPullRequest'], + }); + expect(invalidateQueriesMock).toHaveBeenCalledWith(['providerReview', 'listDiscussions']); + }); +}); + +describe('useCreateReviewCommentMutation (s6 bitbucket arm)', () => { + beforeEach(() => { + resetMocks(); + scopeOverride = { ref: BITBUCKET_REF, organizationId: 'org-9' }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('routes through providerReview.addComment with the workspace identity', async () => { + providerAddCommentMutateMock.mockResolvedValueOnce({ done: true, replayed: false }); + useCreateReviewCommentMutation(BITBUCKET_REF); + + await lastCapturedOptions?.mutationFn?.({ body: 'inline nit' }); + expect(providerAddCommentMutateMock).toHaveBeenCalledWith({ + ...BITBUCKET_IDENTITY, + body: 'inline nit', + operationKey: 'hoisted-op-key', + }); + }); + + it('folds the workspace into the fingerprint (identity rule 17)', async () => { + providerAddCommentMutateMock.mockResolvedValueOnce({ done: true, replayed: false }); + useCreateReviewCommentMutation(BITBUCKET_REF); + + await lastCapturedOptions?.mutationFn?.({ body: 'inline nit' }); + expect(hoistedKeys.getKey).toHaveBeenCalledWith( + '{"resource":["bitbucket","acme","widgets",77],"body":"inline nit"}' + ); + }); +}); + describe('useSubmitReviewMutation (P1-A-08c wiring)', () => { beforeEach(() => { - lastCapturedOptions = null; - submitReviewMutateMock.mockReset(); - invalidateQueriesMock.mockReset(); - toastErrorMock.mockReset(); - hoistedKeys.getKey.mockClear(); - hoistedKeys.rotateKey.mockClear(); + resetMocks(); }); afterEach(() => { @@ -313,6 +509,65 @@ describe('useSubmitReviewMutation (P1-A-08c wiring)', () => { }); }); +describe('useSubmitReviewMutation (s6 provider arms)', () => { + beforeEach(() => { + resetMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('gitlab: routes event + summary through providerReview.submitReview', async () => { + scopeOverride = { ref: GITLAB_REF, organizationId: 'org-9' }; + providerSubmitReviewMutateMock.mockResolvedValueOnce({ done: true, replayed: false }); + useSubmitReviewMutation(GITLAB_REF); + + await lastCapturedOptions?.mutationFn?.({ event: 'approve', body: 'LGTM' }); + expect(submitReviewMutateMock).not.toHaveBeenCalled(); + expect(providerSubmitReviewMutateMock).toHaveBeenCalledWith({ + ...GITLAB_IDENTITY, + event: 'approve', + body: 'LGTM', + operationKey: 'hoisted-op-key', + }); + // Pinned bytes mirroring the server's submitReview fingerprint input. + expect(hoistedKeys.getKey).toHaveBeenCalledWith( + '{"resource":["gitlab","https://gl.example.com","group/sub/app",12],"event":"approve","body":"LGTM"}' + ); + }); + + it('gitlab: an event-only review omits the body from the input and the fingerprint', async () => { + scopeOverride = { ref: GITLAB_REF, organizationId: 'org-9' }; + providerSubmitReviewMutateMock.mockResolvedValueOnce({ done: true, replayed: false }); + useSubmitReviewMutation(GITLAB_REF); + + await lastCapturedOptions?.mutationFn?.({ event: 'comment' }); + const sent = providerSubmitReviewMutateMock.mock.calls[0]?.[0] as Record; + expect(sent).not.toHaveProperty('body'); + expect(hoistedKeys.getKey).toHaveBeenCalledWith( + '{"resource":["gitlab","https://gl.example.com","group/sub/app",12],"event":"comment"}' + ); + }); + + 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 }); + useSubmitReviewMutation(BITBUCKET_REF); + + await lastCapturedOptions?.mutationFn?.({ event: 'request_changes', body: 'Fix this' }); + expect(providerSubmitReviewMutateMock).toHaveBeenCalledWith({ + ...BITBUCKET_IDENTITY, + event: 'request_changes', + body: 'Fix this', + operationKey: 'hoisted-op-key', + }); + expect(hoistedKeys.getKey).toHaveBeenCalledWith( + '{"resource":["bitbucket","acme","widgets",77],"event":"request_changes","body":"Fix this"}' + ); + }); +}); + describe('create_review_comment fingerprint (P1-A-08c changed-input)', () => { it('stays stable for a retry of the same comment and rotates when any intent input changes', () => { const original = prIntentFingerprint('create_review_comment', COMMENT_INPUT); @@ -365,3 +620,29 @@ describe('submit_review fingerprint (P1-A-08c changed-input)', () => { expect(changedComment).not.toBe(original); }); }); + +describe('provider pending-comment body builders (s6)', () => { + 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( + 'src/a.ts:L10\n\nnote' + ); + expect( + 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'); + expect( + buildProviderSubmitBody('Looks good overall.', [ + { path: 'src/a.ts', line: 10, body: 'first' }, + { path: 'src/b.ts', line: 2, startLine: 1, body: 'second' }, + ]) + ).toBe('Looks good overall.\n\nsrc/a.ts:L10\n\nfirst\n\nsrc/b.ts:L1–L2\n\nsecond'); + }); + 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(''); + }); +}); 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 3831e04caf..2fbedfd5b1 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 @@ -1,10 +1,10 @@ // S7a mutation hooks for inline PR review comments and the pending- -// review batch submit. The pattern mirrors the existing S8 merge -// mutations: +// review batch submit, s6: provider-aware through the `providerReview` seam. +// The pattern mirrors the existing S8 merge mutations: // - `onError` toasts the message // - `onSettled` invalidates the PR review queries that the // mutation could have invalidated (overview `getPullRequest` for -// `submitReview` because reviewDecision may flip; `listReviewThreads` +// `submitReview` because reviewDecision may flip; the thread list // for both because a new thread lands immediately) // - the sheet / composer ALSO renders inline errors because toasts // paint behind formSheets on iOS @@ -16,13 +16,29 @@ // 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 +// stay byte-identical: same procedures, same inputs, same fingerprints. +// // P1-A-08c: both hooks hoist one operation key per intent, so retries of the // same intent dedupe on the server instead of re-executing the write. The -// ledger markers map onto the existing per-surface copy. +// ledger markers map onto the existing per-surface copy. The provider key +// fingerprint folds the s1 provider identity (platform + instance hint / +// workspace), so a GitLab comment and a same-named GitHub comment can never +// share a ledger key — the same bytes the server hashes into `resource_key`. import { useMutation, useQueryClient } from '@tanstack/react-query'; import { prIntentFingerprint } from '@kilocode/app-shared/pr-review'; +import { + type BitbucketPrRef, + type GitLabMrRef, + type ProviderPrRef, +} from '@kilocode/app-shared/provider-review'; import { type inferRouterInputs, type MobileRouter } from '@kilocode/trpc/mobile'; import { i18n } from '@/i18n'; @@ -30,6 +46,12 @@ import { announceForA11y } from '@/lib/a11y/announce'; import { announcingToast } from '@/lib/a11y/announcing-toast'; import { trpcClient, useTRPC } from '@/lib/trpc'; import { useHoistedOperationKey } from '@/lib/operation-key'; +import { + type ProviderPrScope, + providerPrTriple, + type ProviderPrTriple, + useProviderPrScope, +} from '@/lib/pr-review/provider-pr-ref'; import { isPrMutationRetryable, mapPrOperationError, @@ -38,11 +60,108 @@ import { type PrRef = { owner: string; repo: string; number: number }; -function usePrRefKeys(ref: PrRef) { +/** Any ref the review write hooks accept: the legacy triple or a provider ref. */ +export type ReviewWriteRef = ProviderPrRef | PrRef; + +/** The legacy triple behind any accepted ref. */ +function reviewWriteTriple(ref: ReviewWriteRef): ProviderPrTriple { + return 'platform' in ref ? providerPrTriple(ref) : ref; +} + +/** + * The provider write identity every `providerReview` mutation input carries. + * Mirrors `providerIdentity()` in `provider-pr-queries.ts` exactly, so the + * invalidation keys below hit the same cache entries the reads populate. + */ +export type ProviderWriteIdentity = + | { + platform: 'gitlab'; + projectPath: string; + mrIid: number; + instanceHint?: string; + organizationId?: string; + } + | { + platform: 'bitbucket'; + workspace: string; + repoSlug: string; + prId: number; + organizationId: string; + }; + +/** The mutation-side identity of one scope — the read-side twin, unchanged. */ +export function providerWriteIdentity(scope: ProviderPrScope): ProviderWriteIdentity { + const { ref, organizationId } = scope; + if (ref.platform === 'gitlab') { + return { + platform: 'gitlab', + projectPath: ref.projectPath, + mrIid: ref.mrIid, + ...(ref.instanceHint ? { instanceHint: ref.instanceHint } : {}), + ...(organizationId ? { organizationId } : {}), + }; + } + return { + platform: 'bitbucket', + workspace: ref.platform === 'bitbucket' ? ref.workspace : '', + repoSlug: ref.platform === 'bitbucket' ? ref.repoSlug : '', + prId: ref.platform === 'bitbucket' ? ref.prId : 0, + organizationId: organizationId ?? '', + }; +} + +/** + * The fingerprint input the server hashes into `resource_key`: the s1 + * provider identity fields (`gitlabFingerprintInput` / + * `bitbucketFingerprintInput` in provider-review-router.ts) plus the + * intent-defining fields. Field VALUES must match the server's — the + * fingerprint is the dedupe identity, and a drift makes every same-key + * retry fail with `operation_key_reuse_mismatch`. + */ +export function providerFingerprintInput( + ref: GitLabMrRef | BitbucketPrRef, + fields: Record +) { + if (ref.platform === 'gitlab') { + return { + platform: 'gitlab', + projectPath: ref.projectPath, + instanceHint: ref.instanceHint, + number: ref.mrIid, + ...fields, + }; + } + return { + platform: 'bitbucket', + workspace: ref.workspace, + repoSlug: ref.repoSlug, + number: ref.prId, + ...fields, + }; +} + +/** + * The write scope: the caller's ref paired with the organization the live + * provider scope publishes. On the GitHub route the context is absent and + * the organization is null — exactly what the GitHub arms already sent. + */ +export function useReviewWriteScope(ref: ReviewWriteRef): ProviderPrScope { + const scope = useProviderPrScope(reviewWriteTriple(ref)); + return 'platform' in ref ? { ref, organizationId: scope.organizationId } : scope; +} + +function usePrRefKeys(scope: ProviderPrScope) { const trpc = useTRPC(); + if (scope.ref.platform === 'github') { + const triple = providerPrTriple(scope.ref); + return { + getPullRequest: trpc.githubPrReview.getPullRequest.queryKey(triple), + threadsPath: trpc.githubPrReview.listReviewThreads.pathFilter(), + }; + } return { - getPullRequest: trpc.githubPrReview.getPullRequest.queryKey(ref), - listReviewThreadsPath: trpc.githubPrReview.listReviewThreads.pathFilter(), + getPullRequest: trpc.providerReview.getPullRequest.queryKey(providerWriteIdentity(scope)), + threadsPath: trpc.providerReview.listDiscussions.pathFilter(), }; } @@ -52,27 +171,95 @@ async function invalidateReviewCaches( ): Promise { await Promise.all([ queryClient.invalidateQueries({ queryKey: keys.getPullRequest }), - queryClient.invalidateQueries(keys.listReviewThreadsPath), + queryClient.invalidateQueries(keys.threadsPath), ]); } type RouterInputs = inferRouterInputs; -export type CreateReviewCommentInput = RouterInputs['githubPrReview']['createReviewComment']; +type CreateReviewCommentInput = RouterInputs['githubPrReview']['createReviewComment']; export type SubmitReviewInput = RouterInputs['githubPrReview']['submitReview']; export type SubmitReviewComment = NonNullable[number]; -export function useCreateReviewCommentMutation(ref: PrRef) { +/** The provider arms accept a body only — no inline position, no batch. */ +type ProviderCommentBody = { body: string }; +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. + */ +export function formatPendingCommentBody(item: { + path: string; + line: number; + startLine?: number; + body: string; +}): 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}`; +} + +/** + * 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. + */ +export function buildProviderSubmitBody( + summary: string, + items: readonly { + path: string; + line: number; + startLine?: number; + body: string; + }[] +): string { + return [summary.trim(), ...items.map(item => formatPendingCommentBody(item))] + .filter(part => part.length > 0) + .join('\n\n'); +} + +/** The events the `providerReview.submitReview` input accepts. */ +export type ProviderReviewEventOption = 'approve' | 'request_changes' | 'comment'; +type ProviderSubmitReviewVars = { + event: ProviderReviewEventOption; + body?: string; +}; +export type SubmitReviewVars = SubmitReviewInput | ProviderSubmitReviewVars; + +export function useCreateReviewCommentMutation(ref: ReviewWriteRef) { const queryClient = useQueryClient(); - const keys = usePrRefKeys(ref); + const scope = useReviewWriteScope(ref); + const keys = usePrRefKeys(scope); const { getKey, rotateKey } = useHoistedOperationKey(); return useMutation({ - mutationFn: async (input: CreateReviewCommentInput) => { + mutationFn: async (input: CreateReviewCommentVars) => { try { - const result = await trpcClient.githubPrReview.createReviewComment.mutate({ - ...input, - operationKey: getKey(prIntentFingerprint('create_review_comment', input)), + if (scope.ref.platform === 'github') { + const vars = input as CreateReviewCommentInput; + const result = await trpcClient.githubPrReview.createReviewComment.mutate({ + ...vars, + operationKey: getKey(prIntentFingerprint('create_review_comment', vars)), + }); + rotateKey(); + return result; + } + const vars = input as ProviderCommentBody; + const result = await trpcClient.providerReview.addComment.mutate({ + ...providerWriteIdentity(scope), + body: vars.body, + operationKey: getKey( + prIntentFingerprint( + 'create_review_comment', + providerFingerprintInput(scope.ref, { body: vars.body }) + ) + ), }); rotateKey(); return result; @@ -98,17 +285,35 @@ export function useCreateReviewCommentMutation(ref: PrRef) { }); } -export function useSubmitReviewMutation(ref: PrRef) { +export function useSubmitReviewMutation(ref: ReviewWriteRef) { const queryClient = useQueryClient(); - const keys = usePrRefKeys(ref); + const scope = useReviewWriteScope(ref); + const keys = usePrRefKeys(scope); const { getKey, rotateKey } = useHoistedOperationKey(); return useMutation({ - mutationFn: async (input: SubmitReviewInput) => { + mutationFn: async (input: SubmitReviewVars) => { try { - const result = await trpcClient.githubPrReview.submitReview.mutate({ - ...input, - operationKey: getKey(prIntentFingerprint('submit_review', input)), + if (scope.ref.platform === 'github') { + const vars = input as SubmitReviewInput; + const result = await trpcClient.githubPrReview.submitReview.mutate({ + ...vars, + operationKey: getKey(prIntentFingerprint('submit_review', vars)), + }); + rotateKey(); + return result; + } + const vars = input as ProviderSubmitReviewVars; + const result = await trpcClient.providerReview.submitReview.mutate({ + ...providerWriteIdentity(scope), + event: vars.event, + ...(vars.body !== undefined && vars.body.length > 0 ? { body: vars.body } : {}), + operationKey: getKey( + prIntentFingerprint( + 'submit_review', + providerFingerprintInput(scope.ref, { event: vars.event, body: vars.body }) + ) + ), }); rotateKey(); return result; diff --git a/apps/mobile/src/lib/pr-review/viewed-files.test.ts b/apps/mobile/src/lib/pr-review/viewed-files.test.ts index cdc7d6d3bf..d20074ff17 100644 --- a/apps/mobile/src/lib/pr-review/viewed-files.test.ts +++ b/apps/mobile/src/lib/pr-review/viewed-files.test.ts @@ -171,6 +171,71 @@ describe('viewed-files', () => { expect(getItemMock.mock.calls.length).toBe(callsBefore); }); + // s6 (identity rule 17): a provider ref folds the s1 collision-free key + // into the storage key, while the legacy GitHub bytes never change. + describe('provider refs', () => { + const GITLAB_A = { + platform: 'gitlab' as const, + projectPath: 'group/app', + mrIid: 7, + instanceHint: 'https://gitlab.example.com', + }; + const GITLAB_B = { + platform: 'gitlab' as const, + projectPath: 'group/app', + mrIid: 7, + instanceHint: 'https://gitlab.com', + }; + + it('keeps a github ProviderPrRef byte-identical to the legacy triple (migration)', async () => { + setStored({ 'octocat/hello-world#42': { headSha: SHA1, viewedPaths: ['a.ts'] } }); + await expect( + getViewedFiles( + { platform: 'github', owner: 'octocat', repo: 'hello-world', number: 42 }, + SHA1 + ) + ).resolves.toEqual(['a.ts']); + + await toggleViewedFile({ + platform: 'github', + owner: 'octocat', + repo: 'hello-world', + number: 42, + headSha: SHA1, + path: 'b.ts', + }); + // The write landed on the SAME legacy key, not a suffixed one. + const written: Record = JSON.parse(store.get('pr-review-viewed') ?? '{}'); + expect(Object.keys(written)).toEqual(['octocat/hello-world#42']); + }); + + it('separates one GitLab project on two instances', async () => { + await toggleViewedFile({ ...GITLAB_A, headSha: SHA1, path: 'a.ts' }); + await expect(getViewedFiles(GITLAB_A, SHA1)).resolves.toEqual(['a.ts']); + await expect(getViewedFiles(GITLAB_B, SHA1)).resolves.toEqual([]); + }); + + it('separates a GitLab MR from a same-triple GitHub PR', async () => { + await toggleViewedFile({ ...GITLAB_A, headSha: SHA1, path: 'a.ts' }); + await expect( + getViewedFiles({ owner: 'group', repo: 'app', number: 7 }, SHA1) + ).resolves.toEqual([]); + }); + + it('separates same-numbered Bitbucket PRs across workspaces', async () => { + const one = { platform: 'bitbucket' as const, workspace: 'acme', repoSlug: 'app', prId: 7 }; + const other = { + platform: 'bitbucket' as const, + workspace: 'other', + repoSlug: 'app', + prId: 7, + }; + await toggleViewedFile({ ...one, headSha: SHA1, path: 'a.ts' }); + await expect(getViewedFiles(one, SHA1)).resolves.toEqual(['a.ts']); + await expect(getViewedFiles(other, SHA1)).resolves.toEqual([]); + }); + }); + it('a concurrent read during clear never returns the prior paths', async () => { setStored({ 'octocat/hello-world#42': { headSha: SHA1, viewedPaths: ['a.ts'] } }); await expect(getViewedFiles(REF, SHA1)).resolves.toEqual(['a.ts']); diff --git a/apps/mobile/src/lib/pr-review/viewed-files.ts b/apps/mobile/src/lib/pr-review/viewed-files.ts index 01a1143455..c2a7575dd6 100644 --- a/apps/mobile/src/lib/pr-review/viewed-files.ts +++ b/apps/mobile/src/lib/pr-review/viewed-files.ts @@ -1,7 +1,10 @@ import * as SecureStore from 'expo-secure-store'; import { z } from 'zod'; +import { type ProviderPrRef, providerPrRefKey } from '@kilocode/app-shared/provider-review'; + import { deleteAccountMetadata, writeAccountMetadata } from '@/lib/auth/account-metadata-write'; +import { providerPrTriple } from '@/lib/pr-review/provider-pr-ref'; import { PR_REVIEW_VIEWED_KEY } from '@/lib/storage-keys'; type ViewedFileEntry = { @@ -37,7 +40,25 @@ type ViewedFilePrRef = { number: number; }; -function viewedFilesKey(ref: ViewedFilePrRef): string { +/** + * A viewed-files identity: the legacy GitHub triple or any provider ref + * (s6). The GitHub bytes never change — a set stored before the provider + * surface keeps loading — and a provider ref folds the s1 collision-free + * `providerPrRefKey` into the key, so one GitLab project on two instances, + * or a GitLab MR and a same-numbered GitHub PR, can never share a set + * (identity rule 17). + */ +export type ViewedFilesRef = ProviderPrRef | ViewedFilePrRef; + +function viewedFilesKey(ref: ViewedFilesRef): string { + if ('platform' in ref) { + const triple = providerPrTriple(ref); + const legacy = `${triple.owner.toLowerCase()}/${triple.repo.toLowerCase()}#${triple.number}`; + if (ref.platform === 'github') { + return legacy; + } + return `${legacy}@${providerPrRefKey(ref)}`; + } return `${ref.owner.toLowerCase()}/${ref.repo.toLowerCase()}#${ref.number}`; } @@ -102,7 +123,7 @@ async function readMap(): Promise { return parsed; } -export async function getViewedFiles(ref: ViewedFilePrRef, headSha: string): Promise { +export async function getViewedFiles(ref: ViewedFilesRef, headSha: string): Promise { const map = await readMap(); const entry = map[viewedFilesKey(ref)]; if (!entry || entry.headSha !== headSha) { @@ -118,7 +139,7 @@ export async function getViewedFiles(ref: ViewedFilePrRef, headSha: string): Pro * almost certainly stale and shouldn't be re-marked). The map itself is * LRU-trimmed to VIEWED_FILES_PR_LIMIT PRs by most-recently-touched. */ -type ToggleViewedFileInput = ViewedFilePrRef & { +type ToggleViewedFileInput = ViewedFilesRef & { headSha: string; path: string; }; From 47a278ac81feca4d60050736e578961776c7dd05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 7 Sep 2026 15:44:30 +0200 Subject: [PATCH 3/5] chore: entry points: pasted/shared URLs (any provider, self-managed hosts), recents identity, session-linked badge, deep links, connect gate (kwf bring-mobile-gitlab-and-bitb-3792/s7) --- .../[platform]/[...identity]/_layout.tsx | 19 +- .../[...identity]/comment-composer.tsx | 5 + .../[platform]/[...identity]/merge.tsx | 5 + .../[...identity]/review-submit.tsx | 5 + .../agents/session-pr-badge.test.ts | 25 +- .../components/agents/session-pr-badge.tsx | 1 - .../pr-review-connect-gate-view.test.ts | 187 ------- .../pr-review/pr-review-connect-gate-view.ts | 29 -- .../pr-review/pr-review-connect-gate.tsx | 249 +++++++++- .../pr-review/pr-review-entry-screen.test.ts | 456 ++++++++++++++++++ .../pr-review/pr-review-entry-screen.tsx | 56 ++- .../pr-review/pr-review-reconnect-notice.tsx | 42 +- .../pr-review/pr-review-screen.test.tsx | 56 ++- .../components/pr-review/pr-review-screen.tsx | 41 +- apps/mobile/src/i18n/locales/en.json | 33 +- .../src/lib/pr-review/pr-link-paste.test.ts | 76 ++- .../mobile/src/lib/pr-review/pr-link-paste.ts | 5 +- .../pr-review-connect-gate-view.test.ts | 118 +++++ .../src/lib/pr-review/provider-pr-ref.ts | 11 + .../src/lib/pr-review/recent-prs.test.ts | 133 +++++ .../src/lib/session-pr-navigation.test.ts | 37 +- apps/mobile/src/lib/session-pr-navigation.ts | 23 +- .../src/universal-links/routes.test.ts | 63 ++- .../app-shared/src/universal-links/routes.ts | 44 +- 24 files changed, 1370 insertions(+), 349 deletions(-) create mode 100644 apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/comment-composer.tsx create mode 100644 apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/merge.tsx create mode 100644 apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/review-submit.tsx delete mode 100644 apps/mobile/src/components/pr-review/pr-review-connect-gate-view.test.ts delete mode 100644 apps/mobile/src/components/pr-review/pr-review-connect-gate-view.ts create mode 100644 apps/mobile/src/components/pr-review/pr-review-entry-screen.test.ts create mode 100644 apps/mobile/src/lib/pr-review/pr-review-connect-gate-view.test.ts diff --git a/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/_layout.tsx b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/_layout.tsx index 167e64b6a0..5c917ab7f9 100644 --- a/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/_layout.tsx +++ b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/_layout.tsx @@ -3,6 +3,7 @@ import { useMemo } from 'react'; import { appUnlockScreenLayout } from '@/components/app-unlock-screen'; import { InvalidRouteState } from '@/components/invalid-route-state'; +import { PrReviewConnectGate } from '@/components/pr-review/pr-review-connect-gate'; import { useFormSheetDetents } from '@/lib/form-sheet'; import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh'; @@ -103,9 +104,21 @@ export default function ProviderPrReviewLayout() { userId={userId} draftEntityKey={draftEntityKey} > - - - + {/* The provider-aware connect gate (s7): a disconnected reader can + never reach the authenticated queries and mutations below, and a + Bitbucket personal scope gets the terminal org-only explanation + instead of a retry that could not succeed. */} + + + {/* The three write sheets (s6) are siblings of the GitHub route's + sheets: they mount inside this layout, so they see the provider + scope and this PR's single `PendingReviewProvider` queue. */} + + + + + + ); diff --git a/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/comment-composer.tsx b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/comment-composer.tsx new file mode 100644 index 0000000000..4a141c120b --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/comment-composer.tsx @@ -0,0 +1,5 @@ +import { PrReviewCommentComposerScreen } from '@/components/pr-review/pr-review-comment-composer-screen'; + +export default function ProviderPrReviewCommentComposerRoute() { + return ; +} diff --git a/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/merge.tsx b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/merge.tsx new file mode 100644 index 0000000000..e337d7891b --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/merge.tsx @@ -0,0 +1,5 @@ +import { PrReviewMergeScreen } from '@/components/pr-review/pr-review-merge-screen'; + +export default function ProviderPrReviewMergeRoute() { + return ; +} diff --git a/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/review-submit.tsx b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/review-submit.tsx new file mode 100644 index 0000000000..a6f5f35433 --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[platform]/[...identity]/review-submit.tsx @@ -0,0 +1,5 @@ +import { PrReviewReviewSubmitScreen } from '@/components/pr-review/pr-review-review-submit-screen'; + +export default function ProviderPrReviewSubmitRoute() { + return ; +} diff --git a/apps/mobile/src/components/agents/session-pr-badge.test.ts b/apps/mobile/src/components/agents/session-pr-badge.test.ts index bd751d1a1f..263cf7a75f 100644 --- a/apps/mobile/src/components/agents/session-pr-badge.test.ts +++ b/apps/mobile/src/components/agents/session-pr-badge.test.ts @@ -243,7 +243,7 @@ describe('SessionPrBadge mounted', () => { expect(mocks.openExternalUrl).not.toHaveBeenCalled(); }); - it('opens the browser for a GitLab PR on press', async () => { + it('opens the in-app merge request route for a GitLab MR on press', async () => { const renderer = await renderBadge({ pr: pr({ platform: 'gitlab', @@ -255,11 +255,26 @@ describe('SessionPrBadge mounted', () => { const pressable = findHost(renderer.root, 'Pressable')[0]; pressable?.props.onPress(); - expect(mocks.openExternalUrl).toHaveBeenCalledWith( - 'https://gitlab.com/octocat/hello-world/-/merge_requests/42', - { label: 'pull request' } + expect(mocks.push).toHaveBeenCalledWith( + '/(app)/pr-review/gitlab/octocat/hello-world/42?instance=https%3A%2F%2Fgitlab.com' ); - expect(mocks.push).not.toHaveBeenCalled(); + expect(mocks.openExternalUrl).not.toHaveBeenCalled(); + }); + + it('opens the in-app pull request route for a Bitbucket PR on press', async () => { + const renderer = await renderBadge({ + pr: pr({ + platform: 'bitbucket', + url: 'https://bitbucket.org/acme/api/pull-requests/42', + }), + loading: false, + }); + + const pressable = findHost(renderer.root, 'Pressable')[0]; + pressable?.props.onPress(); + + expect(mocks.push).toHaveBeenCalledWith('/(app)/pr-review/bitbucket/acme/api/42'); + expect(mocks.openExternalUrl).not.toHaveBeenCalled(); }); it('opens the browser for a GitHub PR when the PR review flag is off', async () => { diff --git a/apps/mobile/src/components/agents/session-pr-badge.tsx b/apps/mobile/src/components/agents/session-pr-badge.tsx index bf335c7850..b557d3004f 100644 --- a/apps/mobile/src/components/agents/session-pr-badge.tsx +++ b/apps/mobile/src/components/agents/session-pr-badge.tsx @@ -93,7 +93,6 @@ export function SessionPrBadge(props: SessionPrBadgeProps) { } const target = resolveSessionPrTapTarget({ url: pr.url, - number: pr.number, }); if (target.kind === 'in-app') { router.push(target.href); diff --git a/apps/mobile/src/components/pr-review/pr-review-connect-gate-view.test.ts b/apps/mobile/src/components/pr-review/pr-review-connect-gate-view.test.ts deleted file mode 100644 index fd304a7bec..0000000000 --- a/apps/mobile/src/components/pr-review/pr-review-connect-gate-view.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import * as React from 'react'; -import { describe, expect, it, vi } from 'vitest'; - -import '@/i18n'; -import type * as ReactI18next from 'react-i18next'; -import { PrReviewConnectGate } from './pr-review-connect-gate'; -import { - type PrReviewGateView, - selectPrReviewGateView, - type SelectPrReviewGateViewInput, -} from './pr-review-connect-gate-view'; - -vi.mock('react-i18next', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useTranslation: () => { - const i18n = actual.getI18n(); - return { t: i18n.t.bind(i18n), i18n }; - }, - }; -}); - -const base: SelectPrReviewGateViewInput = { - isError: false, - isLoading: false, - connected: true, - revoked: false, -}; - -function viewFor(patch: Partial): PrReviewGateView { - return selectPrReviewGateView({ ...base, ...patch }); -} - -describe('selectPrReviewGateView', () => { - it('returns loading while the query is loading', () => { - expect(viewFor({ isLoading: true })).toBe('loading'); - }); - - it('returns error when the query failed and is not loading', () => { - expect(viewFor({ isError: true })).toBe('error'); - }); - - it('returns error when both error and loading are true', () => { - expect(selectPrReviewGateView({ ...base, isError: true, isLoading: true })).toBe('error'); - }); - - it('returns connect when not connected and not revoked', () => { - expect(viewFor({ connected: false })).toBe('connect'); - }); - - it('returns reconnect when the connection was revoked', () => { - expect(viewFor({ connected: false, revoked: true })).toBe('reconnect'); - }); - - it('returns children when connected', () => { - expect(viewFor({ connected: true })).toBe('children'); - }); - - it('exposes only one happy view and four non-happy header-bearing views', () => { - const inputs: Partial[] = [ - { isLoading: true }, - { isError: true }, - { connected: false }, - { connected: false, revoked: true }, - { connected: true }, - ]; - const views = inputs.map(patch => viewFor(patch)); - expect(views.filter(view => view === 'children')).toHaveLength(1); - expect(views.filter(view => view !== 'children')).toHaveLength(4); - expect(new Set(views).size).toBe(views.length); - }); -}); - -// The gate passes `authorization.isPending` (no data yet) to the view -// selector, not `isLoading` (isPending && isFetching). A paused query -// (offline/unknown connectivity, empty cache) is pending but not fetching, -// so `isLoading` is false and the gate would otherwise fall through to -// Connect on a cold launch before NetInfo settles. This pins that wiring: -// a revert to `isLoading` would make the paused query render Connect and -// fail the assertions below. -// -// Rendered as a plain function call (same pattern as pr-review-screen.test.tsx) -// with hooks and child components stubbed so the tree walk stays deterministic. - -let authorizationQueryResult = { - data: undefined as unknown, - isPending: true, - isLoading: false, - isError: false, - isFetching: false, - refetch: vi.fn(), -}; - -vi.mock('react', async () => { - const actual = await vi.importActual('react'); - return { - ...actual, - useCallback: vi.fn( unknown>(fn: T) => fn), - useState: vi.fn((initial: T) => [initial, vi.fn() as () => void] as [T, (value: T) => void]), - useRef: vi.fn((initial: T) => ({ current: initial })), - useEffect: vi.fn(), - }; -}); - -vi.mock('@tanstack/react-query', () => ({ - useQuery: () => authorizationQueryResult, - useMutation: () => ({ mutateAsync: vi.fn() }), - useQueryClient: () => ({ invalidateQueries: vi.fn() }), -})); - -vi.mock('@/lib/trpc', () => ({ - useTRPC: () => ({ - githubApps: { - getUserAuthorization: { queryOptions: () => ({}), queryKey: () => [] }, - connectUserAuthorization: { mutationOptions: () => ({}) }, - }, - }), -})); - -vi.mock('@/lib/hooks/use-theme-colors', () => ({ - useThemeColors: () => ({ mutedForeground: '#6F6A61', primaryForeground: '#FFFFFF' }), -})); - -vi.mock('react-native-safe-area-context', () => ({ - useSafeAreaInsets: () => ({ bottom: 0 }), -})); - -vi.mock('sonner-native', () => ({ toast: { error: vi.fn() } })); - -vi.mock('@/lib/pr-review/connect-gate-platform', () => ({ - openAuthorizationAndWaitForReturn: vi.fn(), -})); - -vi.mock('@/components/ui/icons', () => ({ - PlugZap: 'PlugZap', - RefreshCcw: 'RefreshCcw', - ShieldAlert: 'ShieldAlert', -})); - -vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); -vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); -vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); -vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); -vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); -vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); - -vi.mock('react-native', () => ({ - ActivityIndicator: 'ActivityIndicator', - AppState: { addEventListener: vi.fn(() => ({ remove: vi.fn() })) }, - Platform: { OS: 'ios' }, - View: 'View', -})); - -function containsType(node: unknown, type: string): boolean { - if (Array.isArray(node)) { - return node.some(child => containsType(child, type)); - } - if (React.isValidElement(node)) { - const element = node; - if (element.type === type) { - return true; - } - const props = element.props as { children?: unknown }; - return containsType(props.children, type); - } - return false; -} - -describe('PrReviewConnectGate wiring', () => { - it('shows loading, not Connect, for a paused authorization query with no data', () => { - authorizationQueryResult = { - data: undefined, - isPending: true, - isLoading: false, - isError: false, - isFetching: false, - refetch: vi.fn(), - }; - - // eslint-disable-next-line new-cap - const tree = PrReviewConnectGate({ children: null }); - - expect(containsType(tree, 'ActivityIndicator')).toBe(true); - expect(containsType(tree, 'EmptyState')).toBe(false); - }); -}); diff --git a/apps/mobile/src/components/pr-review/pr-review-connect-gate-view.ts b/apps/mobile/src/components/pr-review/pr-review-connect-gate-view.ts deleted file mode 100644 index b8f0267f8b..0000000000 --- a/apps/mobile/src/components/pr-review/pr-review-connect-gate-view.ts +++ /dev/null @@ -1,29 +0,0 @@ -export type PrReviewGateView = 'error' | 'loading' | 'connect' | 'reconnect' | 'children'; - -export type SelectPrReviewGateViewInput = { - readonly isError: boolean; - readonly isLoading: boolean; - readonly connected: boolean; - readonly revoked: boolean; -}; - -/** - * Pure view selector for the PR-review connect gate. - * - * The gate is intentionally a simple priority ladder: error → loading → - * not-connected → children. This mirrors the original component's branch - * order and keeps every non-happy outcome in a fixed set of header-bearing - * states. - */ -export function selectPrReviewGateView(args: SelectPrReviewGateViewInput): PrReviewGateView { - if (args.isError) { - return 'error'; - } - if (args.isLoading) { - return 'loading'; - } - if (!args.connected) { - return args.revoked ? 'reconnect' : 'connect'; - } - return 'children'; -} diff --git a/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx b/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx index 474c0dcc2f..e457816bc8 100644 --- a/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx @@ -1,8 +1,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { PlugZap, RefreshCcw, ShieldAlert } from '@/components/ui/icons'; -import { type ReactNode, useCallback, useState } from 'react'; +import { type ReactNode, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Platform, View } from 'react-native'; +import { usePathname } from 'expo-router'; import { CenteredState } from '@/components/centered-state'; import { toast } from 'sonner-native'; @@ -11,36 +12,79 @@ import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import { WEB_BASE_URL } from '@/lib/config'; +import { getBitbucketIntegrationUrl, getGitLabIntegrationUrl } from '@/lib/integration-urls'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { useExternalAuthReturn } from '@/lib/external-auth/use-external-auth-return'; import { openAuthorizationAndWaitForReturn } from '@/lib/pr-review/connect-gate-platform'; -import { selectPrReviewGateView } from './pr-review-connect-gate-view'; +import { selectPrReviewGateView } from '@/lib/pr-review/pr-review-connect-gate-view'; +import { type ProviderPrPlatform } from '@/lib/pr-review/provider-pr-ref'; import { useTRPC } from '@/lib/trpc'; type PrReviewConnectGateProps = { readonly children: ReactNode; + /** + * Which provider's connection this mount checks. The GitHub detail route + * and every pre-s7 mount keep the default; the provider layout passes the + * route's platform. + */ + readonly platform?: ProviderPrPlatform; + /** The selected organization for a provider check; null = personal scope. */ + readonly organizationId?: string | null; }; /** - * Wraps every PR-review surface. The user's GitHub identity (separate from - * a per-org GitHub App installation) is required to post review comments - * via the mobile app — without it, every mutation would 401 in the same - * way. The gate is the single place that handles: + * Wraps every PR-review surface, with one arm per provider: * + * - GitHub: the user's GitHub identity (separate from a per-org GitHub App + * installation) is required to post review comments — the existing + * `getUserAuthorization` check and `connectUserAuthorization` CTA. + * - GitLab (personal + org) and Bitbucket (org): the integration status the + * s4 endpoints expose; the CTA opens the web integration page and the + * status refetches when the app returns. + * - Bitbucket personal: Cloud has no personal review scope, so the gate + * shows the org-only explanation with no CTA — nothing a retry could fix. + * + * Each arm handles the same states: * - happy: connected → render children - * - retryable: getUserAuthorization fails → QueryError + Retry - * - empty: not connected / revoked → EmptyState CTA - * - non-retryable: structurally n/a (this is a configuration gate, not a - * transient server failure). + * - retryable: the status check fails → QueryError + Retry + * - empty: not connected → EmptyState CTA into the provider's connect flow + * - non-retryable: Bitbucket personal → org-only explanation, no CTA + * + * The entry screen is provider-neutral — a pasted GitLab or Bitbucket link + * must work for a user with no GitHub connection — so the gate is a + * pass-through there; the provider gates protect each detail route. * - * The CTA calls `githubApps.connectUserAuthorization` and opens the + * The GitHub CTA calls `githubApps.connectUserAuthorization` and opens the * returned URL with the platform-appropriate browser launcher (iOS native * auth session that resolves on sheet close; Android custom tab that - * resolves on app-foreground via AppState). Cancellation on either - * platform simply leaves the gate showing — there's nothing to roll - * back because the auth flow is server-driven. + * resolves on app-foreground via AppState). Cancellation on either platform + * simply leaves the gate showing — there's nothing to roll back because the + * auth flow is server-driven. */ -export function PrReviewConnectGate({ children }: PrReviewConnectGateProps) { +export function PrReviewConnectGate({ + children, + platform = 'github', + organizationId = null, +}: PrReviewConnectGateProps) { + const pathname = usePathname(); + // The entry route (`(app)/pr-review` → pathname `/pr-review`) is the one + // PR-review surface that serves all three providers at once; gating it on + // any single connection would lock out the other two. + if (pathname === '/pr-review') { + return <>{children}; + } + if (platform === 'github') { + return {children}; + } + return ( + + {children} + + ); +} + +function GitHubConnectGate({ children }: Readonly<{ children: ReactNode }>) { const trpc = useTRPC(); const queryClient = useQueryClient(); const colors = useThemeColors(); @@ -97,6 +141,7 @@ export function PrReviewConnectGate({ children }: PrReviewConnectGateProps) { }; const view = selectPrReviewGateView({ + platform: 'github', isError: authorization.isError, // `isPending` (no data yet) rather than `isLoading` (isPending && // isFetching): a paused query (offline/unknown connectivity, empty cache) @@ -106,6 +151,7 @@ export function PrReviewConnectGate({ children }: PrReviewConnectGateProps) { isLoading: authorization.isPending, connected: authorization.data?.connected === true, revoked: authorization.data?.revoked === true, + organizationId: null, }); if (view === 'error') { @@ -172,3 +218,176 @@ export function PrReviewConnectGate({ children }: PrReviewConnectGateProps) { return <>{children}; } + +/** + * GitLab / Bitbucket arm. The status query is the s4 integration status the + * connect flow updates; a Bitbucket personal scope disables it entirely + * because there is nothing to check — the selector renders the terminal + * org-only explanation for that case. + */ +function ProviderConnectGate({ + platform, + organizationId, + children, +}: Readonly<{ + platform: Exclude; + organizationId: string | null; + children: ReactNode; +}>) { + const trpc = useTRPC(); + const colors = useThemeColors(); + const { t } = useTranslation(); + + // The two providers answer with different status shapes, so each arm + // subscribes to its own query and only one is enabled per mount — a + // union of the two queryOptions types is not assignable to one useQuery. + const gitlabOptions = useMemo( + () => + organizationId + ? trpc.organizations.reviewAgent.getGitLabStatus.queryOptions({ organizationId }) + : trpc.personalReviewAgent.getGitLabStatus.queryOptions(), + [trpc, organizationId] + ); + const bitbucketOptions = useMemo( + () => + trpc.organizations.reviewAgent.getBitbucketReadiness.queryOptions({ + organizationId: organizationId ?? '', + }), + [trpc, organizationId] + ); + // Bitbucket Cloud is organization-context only (s4): with no organization + // selected there is no endpoint to ask, so the query stays disabled and the + // gate renders the org-only explanation. + const gitlabStatus = useQuery({ ...gitlabOptions, enabled: platform === 'gitlab' }); + const bitbucketStatus = useQuery({ + ...bitbucketOptions, + enabled: platform === 'bitbucket' && organizationId !== null, + }); + const status = platform === 'gitlab' ? gitlabStatus : bitbucketStatus; + + const refetchStatus = useCallback(() => { + void status.refetch(); + }, [status]); + const { markLaunched, clearLaunch } = useExternalAuthReturn(refetchStatus); + const [connecting, setConnecting] = useState(false); + + const handleConnect = async () => { + setConnecting(true); + try { + // The provider connections are web-side integrations: open the + // existing integration page and re-check the status when the app + // returns (pattern: `openAuthorizationAndWaitForReturn`). + markLaunched(); + const integrationUrl = + platform === 'gitlab' + ? getGitLabIntegrationUrl(WEB_BASE_URL, organizationId ?? undefined) + : getBitbucketIntegrationUrl(WEB_BASE_URL, organizationId ?? ''); + const trigger = await openAuthorizationAndWaitForReturn(Platform.OS, integrationUrl); + if (trigger === 'sheet-close') { + clearLaunch(); + await status.refetch(); + } + // Android: the AppState listener in `useExternalAuthReturn` refetches + // when the app returns to foreground; the sentinel stays set until it + // consumes the launch. + } catch { + // The browser failed to open — clear the sentinel so a later unrelated + // foreground doesn't trigger a stray refetch, and keep the gate showing. + clearLaunch(); + } finally { + setConnecting(false); + } + }; + + const view = selectPrReviewGateView({ + platform, + isError: status.isError, + isLoading: status.isPending, + connected: status.data?.connected === true, + // `revoked` is the GitHub App's vocabulary; provider statuses only ever + // answer connected / not connected. + revoked: false, + organizationId, + }); + + if (view === 'org-only') { + return ( + + + + + ); + } + + if (view === 'error') { + return ( + + + { + void status.refetch(); + }} + isRetrying={status.isFetching} + /> + + ); + } + + if (view === 'loading') { + return ( + + + + + + + ); + } + + if (view === 'connect') { + const title = platform === 'gitlab' ? t('common.connectGitlab') : t('common.connectBitbucket'); + return ( + + + { + void handleConnect(); + }} + > + {connecting ? ( + + ) : ( + + )} + {title} + + } + /> + + ); + } + + return <>{children}; +} diff --git a/apps/mobile/src/components/pr-review/pr-review-entry-screen.test.ts b/apps/mobile/src/components/pr-review/pr-review-entry-screen.test.ts new file mode 100644 index 0000000000..3f33461e3a --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-entry-screen.test.ts @@ -0,0 +1,456 @@ +// The entry screen is the first route into a review for every provider +// (s7): the field must accept a GitHub PR, a GitLab MR (gitlab.com or a +// self-managed host) and a Bitbucket PR; recents must keep one row per +// provider for a same-named repository and navigate back to the row's own +// provider route. Rendered through the plain-function-call harness the +// sibling screen tests use, with the real resolver/recents logic on top of +// a mocked SecureStore. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import '@/i18n'; +import type * as ReactI18next from 'react-i18next'; + +import { PrReviewEntryScreen } from './pr-review-entry-screen'; +import { recentPrKey, type RecentPr } from '@/lib/pr-review/recent-prs'; + +const mocks = vi.hoisted(() => ({ + push: vi.fn(), + alert: vi.fn(), + toastError: vi.fn(), + clipboard: { current: '' as string }, +})); + +vi.mock('react-i18next', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => { + const i18n = actual.getI18n(); + return { t: i18n.t.bind(i18n), i18n }; + }, + }; +}); + +vi.mock('expo-router', () => ({ + useFocusEffect: (cb: () => (() => void) | void) => { + cb(); + }, + useRouter: () => ({ push: mocks.push }), +})); + +vi.mock('expo-clipboard', () => ({ + getStringAsync: async () => mocks.clipboard.current, +})); + +// The store doubles as the recents disk: tests seed it directly and assert +// removals by reading it back. +const store = new Map(); +vi.mock('expo-secure-store', () => ({ + getItemAsync: async (key: string) => store.get(key) ?? null, + setItemAsync: async (key: string, value: string) => { + store.set(key, value); + }, + deleteItemAsync: async (key: string) => { + store.delete(key); + }, +})); +vi.mock('@/lib/storage-keys', () => ({ PR_REVIEW_RECENTS_KEY: 'pr-review-recents' })); +vi.mock('@/lib/auth/account-metadata-write', () => ({ + writeAccountMetadata: async (_key: string, write: () => Promise) => write(), + deleteAccountMetadata: async (key: string) => { + store.delete(key); + }, +})); + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Alert: { alert: mocks.alert }, + Pressable: 'Pressable', + TextInput: 'TextInput', + View: 'View', +})); + +vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/icons', () => ({ + Clipboard: 'ClipboardIcon', + Link2: 'Link2', + SearchX: 'SearchX', + X: 'X', +})); +vi.mock('@/components/ui/directional-icons', () => ({ + DirectionalChevronRight: 'DirectionalChevronRight', +})); +vi.mock('@/components/pr-review/pr-review-inbox-list', () => ({ + PrReviewInboxList: 'PrReviewInboxList', +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#6F6A61', primaryForeground: '#FFFFFF' }), +})); +vi.mock('@/lib/a11y/announcing-toast', () => ({ + announcingToast: { error: mocks.toastError }, +})); + +// The screen's hooks run without a React renderer: state and refs live in a +// per-test slot array, so calling the component again re-reads what the +// previous call's setters wrote. +let hookSlots: unknown[] = []; +let hookIndex = 0; + +vi.mock('react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useState: (initial: unknown) => { + const index = hookIndex++; + if (!(index in hookSlots)) { + hookSlots[index] = initial; + } + return [ + hookSlots[index], + (next: unknown) => { + hookSlots[index] = + typeof next === 'function' ? (next as (prev: unknown) => unknown)(hookSlots[index]) : next; + }, + ]; + }, + useRef: (initial: unknown) => { + const index = hookIndex++; + if (!(index in hookSlots)) { + hookSlots[index] = { current: initial }; + } + return hookSlots[index]; + }, + useCallback: (fn: unknown) => fn, + useMemo: (factory: () => unknown) => factory(), + }; +}); + +// Imported after the mocks so the module graph sees them. +type El = { + type?: unknown; + props?: Record; +}; + +function isElement(value: unknown): value is El { + return typeof value === 'object' && value !== null && 'type' in value && 'props' in value; +} + +function collect(node: unknown, typeName: string, out: El[]): void { + if (node == null) { + return; + } + if (Array.isArray(node)) { + for (const child of node) { + collect(child, typeName, out); + } + return; + } + if (!isElement(node)) { + return; + } + if (node.type === typeName) { + out.push(node); + } + for (const value of Object.values(node.props ?? {})) { + collect(value, typeName, out); + } +} + +function findAll(tree: unknown, typeName: string): El[] { + const out: El[] = []; + collect(tree, typeName, out); + return out; +} + +function find(tree: unknown, typeName: string, where: (props: Record) => boolean): El { + const match = findAll(tree, typeName).find(el => where(el.props ?? {})); + if (!match) { + throw new Error(`no ${typeName} matched the predicate`); + } + return match; +} + +function textValues(tree: unknown): string[] { + return findAll(tree, 'Text') + .map(el => { + const child = el.props?.children; + return typeof child === 'string' ? child : ''; + }) + .filter(value => value.length > 0); +} + +function render(): unknown { + hookIndex = 0; + return PrReviewEntryScreen(); +} + +/** Flush the mocked SecureStore's microtask chain to completion. */ +function flush(): Promise { + return new Promise(resolve => { + setTimeout(resolve, 0); + }); +} + +async function renderLoaded(): Promise { + render(); + // Flush the focus-effect recents load. + await flush(); + return render(); +} + +function seedRecents(entries: RecentPr[]): void { + store.set('pr-review-recents', JSON.stringify(entries)); +} + +function storedRecents(): RecentPr[] { + return JSON.parse(store.get('pr-review-recents') ?? '[]') as RecentPr[]; +} + +const SAME_TRIPLE = { owner: 'acme', repo: 'api', number: 7, lastOpenedAt: 1_700_000_000_000 }; + +beforeEach(() => { + vi.clearAllMocks(); + hookSlots = []; + store.clear(); + mocks.clipboard.current = ''; +}); + +afterEach(() => { + store.clear(); +}); + +describe('provider-neutral URL field', () => { + it('labels and placeholders name both review nouns, no provider host', async () => { + seedRecents([]); + const tree = await renderLoaded(); + const input = find(tree, 'TextInput', () => true); + expect(input.props?.placeholder).toBe('Pull request or merge request URL'); + expect(input.props?.accessibilityLabel).toBe('Enter a pull request or merge request URL'); + expect(String(input.props?.placeholder)).not.toContain('github'); + }); + + it('opens a GitHub PR URL on the GitHub route', async () => { + seedRecents([]); + const tree = await renderLoaded(); + const input = find(tree, 'TextInput', () => true); + (input.props?.onChangeText as (value: string) => void)( + 'https://github.com/octocat/hello-world/pull/42' + ); + const open = render(); + ( + find(open, 'Button', p => p.accessibilityLabel === 'Open pull request or merge request') + .props?.onPress as () => void + )(); + expect(mocks.push).toHaveBeenCalledWith('/(app)/pr-review/octocat/hello-world/42'); + }); + + it('opens a self-managed GitLab MR on the provider route with its instance', async () => { + seedRecents([]); + const tree = await renderLoaded(); + const input = find(tree, 'TextInput', () => true); + (input.props?.onChangeText as (value: string) => void)( + 'https://gitlab.example.com/group/sub/repo/-/merge_requests/9' + ); + const open = render(); + ( + find(open, 'Button', p => p.accessibilityLabel === 'Open pull request or merge request') + .props?.onPress as () => void + )(); + expect(mocks.push).toHaveBeenCalledWith( + '/(app)/pr-review/gitlab/group/sub/repo/9?instance=https%3A%2F%2Fgitlab.example.com' + ); + }); + + it('opens a Bitbucket PR on the provider route', async () => { + seedRecents([]); + const tree = await renderLoaded(); + const input = find(tree, 'TextInput', () => true); + (input.props?.onChangeText as (value: string) => void)( + 'https://bitbucket.org/acme/api/pull-requests/7/overview' + ); + const open = render(); + ( + find(open, 'Button', p => p.accessibilityLabel === 'Open pull request or merge request') + .props?.onPress as () => void + )(); + expect(mocks.push).toHaveBeenCalledWith('/(app)/pr-review/bitbucket/acme/api/7'); + }); + + it('toasts the provider-neutral invalid copy for a link no provider serves', async () => { + seedRecents([]); + const tree = await renderLoaded(); + const input = find(tree, 'TextInput', () => true); + (input.props?.onChangeText as (value: string) => void)('https://example.com/blog/post'); + const open = render(); + ( + find(open, 'Button', p => p.accessibilityLabel === 'Open pull request or merge request') + .props?.onPress as () => void + )(); + expect(mocks.toastError).toHaveBeenCalledWith('Not a pull request or merge request link'); + expect(mocks.push).not.toHaveBeenCalled(); + }); + + it('paste replaces the field and opens a GitLab MR straight away', async () => { + seedRecents([]); + mocks.clipboard.current = 'https://gitlab.com/acme/api/-/merge_requests/3'; + const tree = await renderLoaded(); + const paste = find( + tree, + 'Pressable', + p => p.accessibilityLabel === 'Paste pull request or merge request link' + ); + (paste.props?.onPress as () => Promise)(); + await flush(); + expect(mocks.push).toHaveBeenCalledWith( + '/(app)/pr-review/gitlab/acme/api/3?instance=https%3A%2F%2Fgitlab.com' + ); + expect(mocks.toastError).not.toHaveBeenCalled(); + }); + + it('paste of plain text keeps the invalid toast without navigating', async () => { + seedRecents([]); + mocks.clipboard.current = 'just some notes'; + const tree = await renderLoaded(); + const paste = find( + tree, + 'Pressable', + p => p.accessibilityLabel === 'Paste pull request or merge request link' + ); + (paste.props?.onPress as () => Promise)(); + await flush(); + expect(mocks.toastError).toHaveBeenCalledWith('Not a pull request or merge request link'); + expect(mocks.push).not.toHaveBeenCalled(); + }); + + it('shows the clear control only once the field has text', async () => { + seedRecents([]); + const before = await renderLoaded(); + expect(findAll(before, 'Pressable').some(p => p.props?.accessibilityLabel === 'Clear link')).toBe( + false + ); + const input = find(before, 'TextInput', () => true); + (input.props?.onChangeText as (value: string) => void)('anything'); + const after = render(); + expect( + find(after, 'Pressable', p => p.accessibilityLabel === 'Clear link') + ).toBeTruthy(); + }); +}); + +describe('recents identity across providers', () => { + beforeEach(() => { + seedRecents([ + { ...SAME_TRIPLE, title: 'GitHub one' }, + { + ...SAME_TRIPLE, + title: 'GitLab one', + platform: 'gitlab', + instanceHint: 'https://gitlab.example.com', + }, + { ...SAME_TRIPLE, title: 'Bitbucket one', platform: 'bitbucket' }, + ]); + }); + + it('renders one row per provider with a provider label', async () => { + const tree = await renderLoaded(); + const rows = findAll(tree, 'View').filter(p => p.props?.testID === 'recent-row'); + expect(rows).toHaveLength(3); + const labels = textValues(tree); + expect(labels).toContain('GitHub'); + expect(labels).toContain('GitLab'); + expect(labels).toContain('Bitbucket'); + // The GitLab row writes the MR identity with the provider's own separator. + expect(labels).toContain('acme/api!7'); + expect(labels).toContain('acme/api#7'); + }); + + it("row presses navigate to the row's own provider route", async () => { + const tree = await renderLoaded(); + const rowPressables = findAll(tree, 'Pressable').filter(p => p.props?.accessibilityLabel == null); + expect(rowPressables).toHaveLength(3); + const pushes: unknown[] = []; + for (const row of rowPressables) { + mocks.push.mockClear(); + (row.props?.onPress as () => void)(); + pushes.push(mocks.push.mock.calls[0]?.[0]); + } + // Seed order is stored order: GitHub, GitLab, Bitbucket. + expect(pushes).toEqual([ + '/(app)/pr-review/acme/api/7', + '/(app)/pr-review/gitlab/acme/api/7?instance=https%3A%2F%2Fgitlab.example.com', + '/(app)/pr-review/bitbucket/acme/api/7', + ]); + }); + + it('remove confirms with provider-neutral copy and deletes only the targeted row', async () => { + const tree = await renderLoaded(); + const removeGitLab = find( + tree, + 'Button', + p => p.accessibilityLabel === 'Remove acme/api!7 from recents' + ); + (removeGitLab.props?.onPress as () => void)(); + expect(mocks.alert).toHaveBeenCalledWith( + 'Remove from recents?', + 'This review will be removed from your recents.', + expect.arrayContaining([ + expect.objectContaining({ text: 'Cancel' }), + expect.objectContaining({ text: 'Remove' }), + ]) + ); + const destructive = ( + mocks.alert.mock.calls[0]?.[2] as { text: string; onPress?: () => void }[] + ).find(button => button.text === 'Remove'); + destructive?.onPress?.(); + await flush(); + const remaining = storedRecents(); + expect(remaining).toHaveLength(2); + expect(remaining.map(entry => recentPrKey(entry))).toEqual([ + 'github||acme/api#7', + 'bitbucket||acme/api#7', + ]); + }); + + it('a failed row keeps the retry CTA and provider label', async () => { + store.clear(); + seedRecents([ + { + ...SAME_TRIPLE, + title: 'Broken MR', + platform: 'gitlab', + instanceHint: 'https://gl.acme.dev', + lastResult: 'failed', + }, + ]); + const tree = await renderLoaded(); + expect(textValues(tree)).toContain("Couldn't load"); + const retry = find(tree, 'Button', p => p.accessibilityLabel === 'Retry'); + (retry.props?.onPress as () => void)(); + expect(mocks.push).toHaveBeenCalledWith( + '/(app)/pr-review/gitlab/acme/api/7?instance=https%3A%2F%2Fgl.acme.dev' + ); + }); + + it('an empty recents store shows the neutral empty state', async () => { + store.clear(); + seedRecents([]); + const tree = await renderLoaded(); + const empty = find(tree, 'EmptyState', () => true); + expect(empty.props?.title).toBe('No recent reviews'); + expect(empty.props?.description).toBe( + "Paste a link above to start a review — it'll show up here next time." + ); + }); + + it('the recents load renders into an indicator while pending', () => { + // getRecentPrs is still in flight on the first call: the body is the + // spinner, never a blank that would jump the layout on arrival. + store.set('pr-review-recents', JSON.stringify([{ ...SAME_TRIPLE, title: 'X' }])); + const tree = render(); + expect(findAll(tree, 'ActivityIndicator').length).toBeGreaterThan(0); + }); +}); diff --git a/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx index 0f9c5bdb08..ae3da8edce 100644 --- a/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx @@ -13,19 +13,26 @@ import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { announcingToast } from '@/lib/a11y/announcing-toast'; -import { parseGitHubPrUrl } from '@/lib/github-pr-url'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { getPrReviewPath } from '@/lib/profile-agent-navigation'; +import { + providerPrRefLabel, + providerPrRoutePath, +} from '@/lib/pr-review/provider-pr-ref'; import { consumePrLinkInputEcho, pushPrLinkInputEcho } from '@/lib/pr-review/pr-link-input-echo'; import { + decidePrLinkOpen, decidePrLinkPaste, prLinkToastClipboardEmptyCopy, prLinkToastInvalidCopy, selectPrLinkClearButtonVisible, } from '@/lib/pr-review/pr-link-paste'; -import { getRecentPrs, type RecentPr, removeRecentPr } from '@/lib/pr-review/recent-prs'; - -const URL_PLACEHOLDER = 'https://github.com/owner/repo/pull/123'; +import { + getRecentPrs, + providerRefFromRecentPr, + recentPrKey, + type RecentPr, + removeRecentPr, +} from '@/lib/pr-review/recent-prs'; export function PrReviewEntryScreen() { const router = useRouter(); @@ -71,16 +78,18 @@ export function PrReviewEntryScreen() { }; const handleSubmit = () => { - const raw = inputValueRef.current; - const parsed = parseGitHubPrUrl(raw.trim()); - if (!parsed) { + const decision = decidePrLinkOpen(inputValueRef.current); + if (decision.kind === 'invalid') { announcingToast.error(prLinkToastInvalidCopy()); return; } - // Navigate straight to the PR route. Recents are written only after an - // authorized payload (the PR screen's backfill effect), so a failed or - // unauthorized open never persists an entry. - router.push(getPrReviewPath(parsed.owner, parsed.repo, parsed.number)); + // Navigate straight to the ref's own provider route. Recents are written + // only after an authorized payload (the review screen's backfill effect), + // so a failed or unauthorized open never persists an entry. A + // self-managed GitLab host parses to a ref whose instanceHint rides as + // the route's `instance` param — the server re-derives the authoritative + // instance, so a mismatched host lands on the clear not-authorized state. + router.push(providerPrRoutePath(decision.ref)); }; const handlePaste = async () => { @@ -104,9 +113,11 @@ export function PrReviewEntryScreen() { }; const handleRecentPress = (entry: RecentPr) => { - // Navigate only. The PR screen's backfill effect updates `lastOpenedAt` - // (and `lastResult`) once an authorized payload loads. - router.push(getPrReviewPath(entry.owner, entry.repo, entry.number)); + // Navigate only. The review screen's backfill effect updates + // `lastOpenedAt` (and `lastResult`) once an authorized payload loads. + // The entry's platform (legacy entries: GitHub) decides the route, so a + // GitLab row opens the GitLab surface, never a same-named GitHub PR. + router.push(providerPrRoutePath(providerRefFromRecentPr(entry))); }; const handleRemoveRecent = (entry: RecentPr) => { @@ -156,11 +167,12 @@ export function PrReviewEntryScreen() { const isLast = index === recent.length - 1; const rowState = selectRecentPrRowState(entry); const removeLabel = t('prReview.entry.removeRecentAccessibility', { - repo: `${entry.owner}/${entry.repo}#${entry.number}`, + repo: providerPrRefLabel(providerRefFromRecentPr(entry)), }); return ( + + {rowState.provider} + {rowState.primary} @@ -239,7 +257,7 @@ export function PrReviewEntryScreen() { {t('prReview.entry.open')} diff --git a/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx b/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx index 1ea7172108..eefea32abe 100644 --- a/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx @@ -3,24 +3,56 @@ import { View } from 'react-native'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import { useProviderPrScopeOrNull } from '@/lib/pr-review/provider-pr-ref'; import { useCheckGitHubConnection } from '@/lib/pr-review/use-check-github-connection'; +import { useCheckProviderConnection } from '@/lib/pr-review/use-check-provider-connection'; +/** + * The mid-session recovery notice for an expired provider connection. The + * surface that caught the precondition failure renders it; this component + * decides WHICH connection to re-check from the provider scope the route + * published — the GitHub route publishes none, so the GitHub arm (and the + * original copy) is the fallback. A GitLab or Bitbucket surface re-checks + * its own integration status instead, because a GitHub retry can never fix + * an expired GitLab connection. + */ export function PrReviewReconnectNotice() { const connection = useCheckGitHubConnection(); + const providerConnection = useCheckProviderConnection(); + const scope = useProviderPrScopeOrNull(); + const platform = scope?.ref.platform ?? 'github'; + const organizationId = scope?.organizationId ?? null; + const providerPlatform = platform === 'github' ? null : platform; const { t } = useTranslation(); + const title = + platform === 'gitlab' + ? t('prReview.reconnectNotice.gitlabTitle') + : platform === 'bitbucket' + ? t('prReview.reconnectNotice.bitbucketTitle') + : t('prReview.reconnectNotice.title'); + const message = + providerPlatform === null + ? t('prReview.reconnectNotice.message') + : t('prReview.reconnectNotice.providerMessage', { + provider: + platform === 'gitlab' ? t('common.gitlab') : t('agentChat.repoPicker.platformBitbucket'), + }); + return ( - - {t('prReview.reconnectNotice.title')} - - {t('prReview.reconnectNotice.message')} + {title} + {message}