Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions apps/mobile/src/components/agents/chat-markdown-text.mounted.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/* eslint-disable max-lines -- one mounted harness covers the tap and long-press review-link states for all three providers. */
import { createElement } from 'react';
import { act, TestRenderer } from '@/test/renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { ChatMarkdownText } from './chat-markdown-text';

const mocks = vi.hoisted(() => ({
showActionSheetWithOptions: vi.fn(),
push: vi.fn(),
openExternalUrl: vi.fn(),
markdownProps: [] as Record<string, unknown>[],
}));

vi.mock('react-native', () => ({ Share: { share: vi.fn() } }));
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }));
vi.mock('sonner-native', () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
vi.mock('@expo/react-native-action-sheet', () => ({
useActionSheet: () => ({ showActionSheetWithOptions: mocks.showActionSheetWithOptions }),
}));
vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) }));
vi.mock('expo-router', () => ({ useRouter: () => ({ push: mocks.push }) }));
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
vi.mock('@/i18n', () => ({ i18n: { language: 'en', t: (key: string) => key } }));
vi.mock('@/lib/analytics/posthog', () => ({
FEATURE_FLAG_PR_REVIEW: 'pr-review',
useFeatureFlag: () => true,
}));
vi.mock('@/lib/external-link', () => ({ openExternalUrl: mocks.openExternalUrl }));
vi.mock('@/components/agents/use-message-copy', () => ({ performCopy: vi.fn() }));
vi.mock('./markdown-link-confirm', () => ({ formatLinkHost: (href: string) => href }));
vi.mock('./markdown-text', () => ({
MarkdownText: (props: Record<string, unknown>) => {
mocks.markdownProps.push(props);
return null;
},
}));

function mountChatMarkdownText(): {
onPressLink: (href: string) => boolean;
onLongPressLink: (href: string) => void;
} {
const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
act(() => {
ref.current = TestRenderer.create(createElement(ChatMarkdownText, { value: 'text' }));
});
const props = mocks.markdownProps.at(-1);
if (!props) {
throw new Error('ChatMarkdownText did not render its markdown surface');
}
return {
onPressLink: props.onPressLink as (href: string) => boolean,
onLongPressLink: props.onLongPressLink as (href: string) => void,
};
}

/** The options array handed to the native action sheet on the last call. */
function lastSheetOptions(): string[] {
const call = mocks.showActionSheetWithOptions.mock.calls.at(-1);
const sheet = call?.[0] as { options?: string[] } | undefined;
return sheet?.options ?? [];
}

/** Invoke the sheet's pressed index through the handler the sheet received. */
function pressSheetOption(index: number): void {
const call = mocks.showActionSheetWithOptions.mock.calls.at(-1);
const onSelect = call?.[1] as ((index: number) => void) | undefined;
onSelect?.(index);
}

describe('ChatMarkdownText review-link recognition', () => {
beforeEach(() => {
mocks.showActionSheetWithOptions.mockReset();
mocks.push.mockReset();
mocks.openExternalUrl.mockReset();
mocks.markdownProps = [];
});

it('offers Review PR for a github.com pull request and routes in-app', () => {
const { onPressLink } = mountChatMarkdownText();

const handled = onPressLink('https://github.com/octocat/hello-world/pull/42');

expect(handled).toBe(true);
expect(lastSheetOptions()).toContain('common.reviewPr');
pressSheetOption(0);
expect(mocks.push).toHaveBeenCalledWith('/(app)/pr-review/octocat/hello-world/42');
});

it('offers Review PR for a gitlab.com merge request and routes to the provider route', () => {
const { onPressLink } = mountChatMarkdownText();

const handled = onPressLink('https://gitlab.com/group/sub/repo/-/merge_requests/7');

expect(handled).toBe(true);
expect(lastSheetOptions()).toContain('common.reviewPr');
pressSheetOption(0);
expect(mocks.push).toHaveBeenCalledWith(
'/(app)/pr-review/gitlab/group/sub/repo/7?instance=https%3A%2F%2Fgitlab.com'
);
});

it('offers Review PR for a self-managed GitLab merge request', () => {
const { onPressLink } = mountChatMarkdownText();

const handled = onPressLink('https://gitlab.example.com/team/repo/-/merge_requests/9');

expect(handled).toBe(true);
pressSheetOption(0);
expect(mocks.push).toHaveBeenCalledWith(
'/(app)/pr-review/gitlab/team/repo/9?instance=https%3A%2F%2Fgitlab.example.com'
);
});

it('offers Review PR for a Bitbucket pull request and routes to the provider route', () => {
const { onPressLink } = mountChatMarkdownText();

const handled = onPressLink('https://bitbucket.org/acme/api/pull-requests/42/overview');

expect(handled).toBe(true);
pressSheetOption(0);
expect(mocks.push).toHaveBeenCalledWith('/(app)/pr-review/bitbucket/acme/api/42');
});

it('leaves a plain link to the default open-in-browser path', () => {
const { onPressLink } = mountChatMarkdownText();

expect(onPressLink('https://example.com/docs')).toBe(false);
expect(mocks.showActionSheetWithOptions).not.toHaveBeenCalled();
});

it('adds the long-press Review PR option for GitLab and Bitbucket, not for plain links', () => {
const { onLongPressLink } = mountChatMarkdownText();

onLongPressLink('https://gitlab.com/group/repo/-/merge_requests/7');
expect(lastSheetOptions()).toContain('common.reviewPr');
pressSheetOption(0);
expect(mocks.push).toHaveBeenCalledWith(
'/(app)/pr-review/gitlab/group/repo/7?instance=https%3A%2F%2Fgitlab.com'
);

onLongPressLink('https://example.com/docs');
expect(lastSheetOptions()).not.toContain('common.reviewPr');
});
});
21 changes: 6 additions & 15 deletions apps/mobile/src/components/agents/chat-markdown-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { FEATURE_FLAG_PR_REVIEW, useFeatureFlag } from '@/lib/analytics/posthog';
import { openExternalUrl } from '@/lib/external-link';
import { parseGitHubPrUrl } from '@/lib/github-pr-url';
import { providerPrRoutePath } from '@/lib/pr-review/provider-pr-ref';
import { parseProviderPrUrl } from '@/lib/pr-review/provider-pr-url';

import {
buildChatLinkActionSheet,
Expand All @@ -31,18 +32,8 @@ function sheetMessage(href: string): string {
}

function buildPrReviewHref(href: string): Href | null {
const parsed = parseGitHubPrUrl(href);
if (!parsed) {
return null;
}
return {
pathname: '/(app)/pr-review/[owner]/[repo]/[number]',
params: {
owner: parsed.owner,
repo: parsed.repo,
number: String(parsed.number),
},
};
const ref = parseProviderPrUrl(href);
return ref ? providerPrRoutePath(ref) : null;
}

export function ChatMarkdownText(props: Readonly<ChatMarkdownTextProps>) {
Expand All @@ -56,7 +47,7 @@ export function ChatMarkdownText(props: Readonly<ChatMarkdownTextProps>) {
(href: string) => {
// When PR Review is off, PR links behave like any other link (default
// open-in-browser) instead of showing the Review-PR tap sheet.
if (!prReviewEnabled || !parseGitHubPrUrl(href)) {
if (!prReviewEnabled || !parseProviderPrUrl(href)) {
return false;
}
// Tap on a PR link shows exactly four options: Review PR / Open in
Expand Down Expand Up @@ -96,7 +87,7 @@ export function ChatMarkdownText(props: Readonly<ChatMarkdownTextProps>) {
const handleLongPressLink = useCallback(
(href: string, event?: GestureResponderEvent) => {
event?.stopPropagation();
const isPrLink = prReviewEnabled && parseGitHubPrUrl(href) !== null;
const isPrLink = prReviewEnabled && parseProviderPrUrl(href) !== null;
const sheet = buildChatLinkActionSheet({ isPrLink });
showActionSheetWithOptions(
{
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/components/agents/session-pr-badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ export type SessionPrBadgeProps = Readonly<{
* Shows `#N` with an icon/accent derived from the PR state and review
* decision. While `loading` is true it reserves a fixed-width skeleton; after
* the fetch it renders the badge or nothing (never a guess from `git_branch`).
* Tapping navigates in-app for GitHub PRs and opens the browser otherwise.
* Tapping routes through the one provider URL resolver: a GitHub pull request,
* a GitLab merge request (any host) and a Bitbucket pull request all open the
* in-app review route, and anything else opens the browser.
*/
export function SessionPrBadge(props: SessionPrBadgeProps) {
const { loading } = props;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ vi.mock('@/lib/analytics/posthog', () => ({
useFeatureFlag: () => true,
}));
vi.mock('@/lib/code-reviewer-open-pr-destination', () => ({
resolveCodeReviewerOpenPrDestination: () => ({ kind: 'external' }),
resolveCodeReviewerOpenPrDestination: () => ({ kind: 'browser' }),
}));
vi.mock('@/lib/code-reviewer-config', () => ({
reviewerPlatformLabel: () => 'GitHub',
Expand All @@ -156,7 +156,6 @@ vi.mock('@/lib/hooks/use-code-reviews', () => ({
useCancelReview: () => ({ isPending: false, mutate: vi.fn() }),
useRetriggerReview: () => ({ isPending: false, mutate: vi.fn() }),
}));
vi.mock('@/lib/profile-agent-navigation', () => ({ getPrReviewPath: vi.fn() }));
vi.mock('@/lib/utils', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
parseTimestamp: (value: unknown) => value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import { reviewerPlatformLabel } from '@/lib/code-reviewer-config';
import { openExternalUrl } from '@/lib/external-link';
import { formatMoney, formatNumber } from '@/lib/format';
import { useCancelReview, useRetriggerReview, useReviewDetail } from '@/lib/hooks/use-code-reviews';
import { getPrReviewPath } from '@/lib/profile-agent-navigation';
import { cn, parseTimestamp, timeAgo } from '@/lib/utils';

const FINDINGS_PAGE_SIZE = 20;
Expand Down Expand Up @@ -268,9 +267,7 @@ export function ReviewDetailScreen({
prReviewEnabled
);
if (destination.kind === 'in-app') {
router.push(
getPrReviewPath(destination.owner, destination.repo, destination.number)
);
router.push(destination.href);
return;
}
void openExternalUrl(review.pr_url, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,16 +274,26 @@ describe('FindingRemediationPanel pull request navigation', () => {
});

it.each([
['https://github.com/kilo/kilo/pull/123', true, true],
['https://github.com/kilo/kilo/pull/123', false, false],
['https://gitlab.com/kilo/kilo/-/merge_requests/123', true, false],
] as const)('opens %s with PR review=%s in-app=%s', (prUrl, enabled, inApp) => {
['https://github.com/kilo/kilo/pull/123', true, '/(app)/pr-review/kilo/kilo/123'],
['https://github.com/kilo/kilo/pull/123', false, null],
[
'https://gitlab.com/kilo/kilo/-/merge_requests/123',
true,
'/(app)/pr-review/gitlab/kilo/kilo/123?instance=https%3A%2F%2Fgitlab.com',
],
[
'https://bitbucket.org/kilo/kilo/pull-requests/123',
true,
'/(app)/pr-review/bitbucket/kilo/kilo/123',
],
['https://github.example.com/kilo/kilo/pull/123', true, null],
] as const)('opens %s with PR review=%s', (prUrl, enabled, expectedHref) => {
mocks.prReviewEnabled = enabled;
pressButtons(
renderPanel(analysisFixture({ remediationSummary: { status: 'pr_opened', prUrl } }))
);
if (inApp) {
expect(mocks.routerPush).toHaveBeenCalledWith('/(app)/pr-review/kilo/kilo/123');
if (expectedHref) {
expect(mocks.routerPush).toHaveBeenCalledWith(expectedHref);
expect(mocks.openExternalUrl).not.toHaveBeenCalled();
} else {
expect(mocks.routerPush).not.toHaveBeenCalled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import {
useStartSecurityRemediation,
} from '@/lib/hooks/use-security-remediation';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { getPrReviewPath } from '@/lib/profile-agent-navigation';
import { type SecurityAnalysis } from '@/lib/security-agent';
import { firstNonEmpty, parseTimestamp, timeAgo } from '@/lib/utils';

Expand Down Expand Up @@ -221,7 +220,7 @@ export function FindingRemediationPanel({
const openPullRequest = (url: string) => {
const destination = resolveCodeReviewerOpenPrDestination(url, prReviewEnabled);
if (destination.kind === 'in-app') {
router.push(getPrReviewPath(destination.owner, destination.repo, destination.number));
router.push(destination.href);
return;
}
void openExternalUrl(url, { label: t('common.pullRequest') });
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/components/share/share-gate-sheet.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -405,4 +405,24 @@ describe('ShareGateSheet spawn operationKey wiring', () => {
renderer.unmount();
});
});

it('numbers a staged GitLab merge request with the provider separator', async () => {
const shareId = putSharePayload({
text: 'https://gitlab.com/group/sub/repo/-/merge_requests/7',
files: [],
failedFiles: [],
});
const renderer = await mountGate(shareId);

// The Review PR row is the row whose subtitle is the destination identity.
const subtitles = renderer.root
.findAllByType('Text')
.filter(node => node.props.className === 'text-sm text-muted-foreground')
.map(node => node.children.filter(child => typeof child === 'string').join(''));
expect(subtitles).toEqual(['group/sub/repo!7']);

act(() => {
renderer.unmount();
});
});
});
12 changes: 4 additions & 8 deletions apps/mobile/src/components/share/share-gate-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { useRemoteInstanceSpawn } from '@/lib/hooks/use-remote-instance-spawn';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { useOrganization } from '@/lib/organization-context';
import { useHoistedOperationKey } from '@/lib/operation-key';
import { getPrReviewPath } from '@/lib/profile-agent-navigation';
import { providerPrRoutePath } from '@/lib/pr-review/provider-pr-ref';
import { resolveRemoteSubmitOutcome } from '@/lib/remote-submit-outcome';
import { appendShareParams, setPendingShareNavigation } from '@/lib/share-navigation';
import {
Expand All @@ -50,7 +50,7 @@ import { ShareDestinationList } from './share-destination-list';
import { isShareCommitEnabled, selectShareGateState } from './share-gate-state';
import { SharePayloadPreview } from './share-payload-preview';
import { type SharePayloadValidation, validateSharePayload } from './share-payload-validation';
import { selectShareReviewPr } from './share-review-pr';
import { selectShareReviewPr, selectShareReviewPrSubtitle } from './share-review-pr';

type ShareGateSheetProps = {
shareId: string | undefined;
Expand Down Expand Up @@ -218,7 +218,7 @@ export function ShareGateSheet({ shareId }: Readonly<ShareGateSheetProps>) {
}
void Haptics.selectionAsync();
setPendingShareNavigation({
href: getPrReviewPath(reviewPr.owner, reviewPr.repo, reviewPr.number) as string,
href: providerPrRoutePath(reviewPr) as string,
shareId: null,
});
dismiss();
Expand Down Expand Up @@ -409,11 +409,7 @@ export function ShareGateSheet({ shareId }: Readonly<ShareGateSheetProps>) {
<DestinationOptionRow
icon={GitPullRequest}
title={t('common.reviewPr')}
subtitle={t('share.reviewPrSubtitle', {
owner: reviewPr.owner,
repo: reviewPr.repo,
number: reviewPr.number,
})}
subtitle={selectShareReviewPrSubtitle(reviewPr)}
accessibilityLabel={t('common.reviewPr')}
onPress={handleReviewPr}
/>
Expand Down
Loading