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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { act, TestRenderer } from '@/test/renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { type MutationErrorDisplaySurface } from '@/lib/pr-review/mutation-error-display';
import { ProviderPrScopeProvider } from '@/lib/pr-review/provider-pr-ref';
import { useComposerInlineError } from './composer-inline-error';

const { ensureTermsAcceptedOutcomeMock, TERMS_CHECK_RETRY, TERMS_OUTDATED } = vi.hoisted(() => ({
Expand Down Expand Up @@ -153,4 +154,31 @@ describe('useComposerInlineError terms gate', () => {

renderer.unmount();
});

it('names the connected provider in the reconnect copy', async () => {
const revoked = new Error('revoked');
Object.assign(revoked, { data: { code: 'PRECONDITION_FAILED' } });
const scope = {
ref: { platform: 'gitlab', projectPath: 'group/repo', mrIid: 1 } as const,
organizationId: null,
};

const holder: { current: TestRenderer.ReactTestRenderer | null } = { current: null };
await act(async () => {
holder.current = TestRenderer.create(
<ProviderPrScopeProvider value={scope}>
{createElement(Harness, { error: revoked, isEdit: false })}
</ProviderPrScopeProvider>
);
await flush();
});
const renderer = holder.current;
if (renderer === null) {
throw new Error('Failed to create test renderer');
}

expect(latestState?.inlineError).toBe('GitLab connection expired');
expect(latestState?.inlineErrorKind).toBe('reconnect');
renderer.unmount();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
mutationErrorDisplay,
type MutationErrorDisplaySurface,
} from '@/lib/pr-review/mutation-error-display';
import { useProviderPrScopeOrNull } from '@/lib/pr-review/provider-pr-ref';

export type ComposerInlineErrorKind =
| 'retryable'
Expand Down Expand Up @@ -74,6 +75,10 @@ export function useComposerInlineError(
// mutation toast owns it), so it must announce through AccessibleStatus;
// mutation-classified errors are toast-owned and stay visual-only.
const [inlineErrorIsLocal, setInlineErrorIsLocal] = useState(false);
// The route publishes the provider scope for every pr-review screen; a
// GitLab/Bitbucket composer names its own provider in the reconnect copy
// instead of announcing a GitHub connection. Null on the GitHub route.
const platform = useProviderPrScopeOrNull()?.ref.platform;

useEffect(() => {
if (isEdit || !error) {
Expand Down Expand Up @@ -103,11 +108,11 @@ export function useComposerInlineError(
})();
return;
}
const display = mutationErrorDisplay(surface, classification, { rawError: error });
const display = mutationErrorDisplay(surface, classification, { rawError: error, platform });
setInlineError(display.message);
setInlineErrorKind(display.kind);
setInlineErrorIsLocal(false);
}, [error, isEdit, surface]);
}, [error, isEdit, surface, platform]);

function clearBadRequestOnBodyEdit() {
// bad-request clears on body change; forbidden stays for the session.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type * as ReactI18next from 'react-i18next';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type * as ComposerInlineErrorModule from '@/components/pr-review/composer-inline-error';
import type * as ProviderPrRefModule from '@/lib/pr-review/provider-pr-ref';
import '@/i18n';
import * as Haptics from 'expo-haptics';
import { PrCommentEditSheet } from './pr-comment-edit-sheet';
Expand Down Expand Up @@ -189,6 +190,14 @@ vi.mock('@/components/pr-review/composer-inline-error', async importOriginal =>
return { ...actual, ComposerInlineError: 'ComposerInlineError' };
});

// The hook reads the route's provider scope. This suite calls the sheet as a
// plain function (no React tree), so the reader reports the GitHub route; the
// provider arms are covered by the composer-inline-error suite.
vi.mock('@/lib/pr-review/provider-pr-ref', async importOriginal => {
const actual = await importOriginal<typeof ProviderPrRefModule>();
return { ...actual, useProviderPrScopeOrNull: () => null };
});

const baseProps = {
owner: 'octocat',
repo: 'hello',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { clearDraft, prConversationCommentDraftKey, saveDraft } from '@/lib/pers
import { useDraftFlushOnBackground } from '@/lib/persist/use-draft-flush';
import { useFencedDraftLoad } from '@/lib/persist/use-draft-load';
import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state';
import { reconnectExpiredMessage } from '@/lib/pr-review/mutation-error-display';
import {
isPrOperationAmbiguous,
isPrOperationPersistenceFailed,
Expand Down Expand Up @@ -73,6 +74,9 @@ export function PrConversationCommentComposer({
const { t } = useTranslation();
const { confirm, dialog } = useConfirmDialog();
const addComment = useAddPrCommentMutation(prRef);
// The provider platform as a stable primitive: the error effect names the
// connected provider without depending on the `prRef` object identity.
const providerPlatform = prRef?.platform;

// Durable comment draft, keyed by account and PR. Nothing is saved or
// restored while the user id is unknown.
Expand Down Expand Up @@ -177,7 +181,7 @@ export function PrConversationCommentComposer({
setInlineError(t('prReview.discussion.commentForbidden'));
setInlineErrorKind('forbidden');
} else if (classification.kind === 'reconnect') {
setInlineError(t('prReview.connectionExpired'));
setInlineError(reconnectExpiredMessage(providerPlatform));
setInlineErrorKind('reconnect');
} else {
// A generic/transient failure shows the specified retryable copy, never
Expand All @@ -188,7 +192,7 @@ export function PrConversationCommentComposer({
setInlineError(t('prReview.mutationError.couldNotPostComment'));
setInlineErrorKind('retryable');
}
}, [addComment.error, t]);
}, [addComment.error, t, providerPlatform]);

function handleBodyChange(value: string) {
bodyRef.current = value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { clearDraft, prReplyDraftKey, saveDraft } from '@/lib/persist/drafts';
import { useDraftFlushOnBackground } from '@/lib/persist/use-draft-flush';
import { useFencedDraftLoad } from '@/lib/persist/use-draft-load';
import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state';
import { reconnectExpiredMessage } from '@/lib/pr-review/mutation-error-display';
import { type useReplyToCommentMutation } from '@/lib/pr-review/discussion/use-review-discussion-mutations';
import {
isPrOperationAmbiguous,
Expand Down Expand Up @@ -265,7 +266,7 @@ export function ReplyInput({
);
setInlineErrorKind('forbidden');
} else if (classification.kind === 'reconnect') {
setInlineError(t('prReview.connectionExpired'));
setInlineError(reconnectExpiredMessage(providerPlatform));
setInlineErrorKind('reconnect');
} else {
// A generic/transient failure shows the specified retryable copy,
Expand Down
39 changes: 39 additions & 0 deletions apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,10 @@ function forbiddenError(): Error {
return Object.assign(new Error('403 Forbidden'), { data: { code: 'FORBIDDEN' } });
}

function revokedError(): Error {
return Object.assign(new Error('revoked'), { data: { code: 'PRECONDITION_FAILED' } });
}

/** 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));
Expand Down Expand Up @@ -734,6 +738,41 @@ describe('PrMergeSheet refused-merge wording (s6f)', () => {
});
});

describe('PrMergeSheet reconnect wording', () => {
beforeEach(() => {
stateSetters.length = 0;
mergeMutationMocks.mutateAsync.mockReset();
mergeMutationMocks.isPending = false;
mergeMutationMocks.error = null;
vi.clearAllMocks();
});

it('names the merge-request provider in the status line above the notice', () => {
mergeMutationMocks.error = revokedError();
// eslint-disable-next-line new-cap
PrMergeSheet({ ...baseProps, prRef: GITLAB_REF, mergeState: mergeState() });
// The status line sits above the GitLab reconnect notice, so it must not
// announce a GitHub connection on a merge request.
expect(stateValueWritten('GitLab connection expired')).toBe(true);
expect(stateValueWritten('GitHub connection expired.')).toBe(false);
});

it('names the Bitbucket provider in the status line above the notice', () => {
mergeMutationMocks.error = revokedError();
// eslint-disable-next-line new-cap
PrMergeSheet({ ...baseProps, prRef: BITBUCKET_REF, mergeState: mergeState() });
expect(stateValueWritten('Bitbucket connection expired')).toBe(true);
expect(stateValueWritten('GitHub connection expired.')).toBe(false);
});

it('keeps the GitHub copy on the GitHub arm', () => {
mergeMutationMocks.error = revokedError();
// eslint-disable-next-line new-cap
PrMergeSheet(baseProps);
expect(stateValueWritten('GitHub connection expired.')).toBe(true);
});
});

describe('PrMergeSheet provider auto-merge arms (s6)', () => {
beforeEach(() => {
confirmRequests.length = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
useMergePullRequestMutation,
} from '@/lib/pr-review/merge/use-pr-merge-mutations';
import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state';
import { reconnectExpiredMessage } from '@/lib/pr-review/mutation-error-display';
import {
isPrOperationPersistenceFailed,
PR_OPERATION_PERSISTENCE_FAILED_MESSAGE,
Expand Down Expand Up @@ -337,7 +338,9 @@ export function PrMergeSheet(props: PrMergeSheetProps) {
);
setInlineErrorKind('non-retryable');
} else if (classification.kind === 'reconnect') {
setInlineError(t('prReview.connectionExpired'));
// The status line sits above the reconnect notice on the provider
// arms, so it must name that provider's connection too.
setInlineError(reconnectExpiredMessage(providerPlatform));
setInlineErrorKind('reconnect');
} else {
setInlineError(
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/components/pr-review/pr-review-submit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ export function PrReviewSubmit(props: PrReviewSubmitProps) {
const display = mutationErrorDisplay('submit', classification, {
rawError: submitReview.error,
term: providerPlatform ? i18n.t(providerPrNounKey(providerPlatform)) : undefined,
platform: providerPlatform,
});
setInlineError(display.message);
setInlineErrorKind(display.kind);
Expand Down
27 changes: 27 additions & 0 deletions apps/mobile/src/lib/pr-review/mutation-error-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,4 +267,31 @@ describe('mutationErrorDisplay', () => {
message: serverMessage,
});
});

it('names the connected provider in the reconnect copy', () => {
const revoked = makeError('PRECONDITION_FAILED', 'revoked');
const classification = classifyPrReviewMutationError(revoked);
expect(classification.kind).toBe('reconnect');

// GitHub (and a surface that carries no platform) keeps the exact copy.
expect(mutationErrorDisplay('submit', classification)).toEqual({
kind: 'reconnect',
message: 'GitHub connection expired.',
});
expect(mutationErrorDisplay('submit', classification, { platform: 'github' })).toEqual({
kind: 'reconnect',
message: 'GitHub connection expired.',
});

// A provider surface names its own connection, matching the notice it
// renders below the status.
expect(mutationErrorDisplay('submit', classification, { platform: 'gitlab' })).toEqual({
kind: 'reconnect',
message: 'GitLab connection expired',
});
expect(mutationErrorDisplay('composer', classification, { platform: 'bitbucket' })).toEqual({
kind: 'reconnect',
message: 'Bitbucket connection expired',
});
});
});
25 changes: 24 additions & 1 deletion apps/mobile/src/lib/pr-review/mutation-error-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import { i18n } from '@/i18n';
import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state';
import { type ProviderPrPlatform } from '@/lib/pr-review/provider-pr-ref';
import {
isPrOperationAmbiguous,
isPrOperationPersistenceFailed,
Expand Down Expand Up @@ -49,8 +50,30 @@ type MutationErrorDisplayOptions = {
* GitHub copy. Absent (GitHub), the exact pre-s6 copy stays.
*/
readonly term?: string;
/**
* The surface's connected provider: the reconnect copy names it so a GitLab
* or Bitbucket surface never announces a GitHub connection. Absent (GitHub)
* keeps `prReview.connectionExpired` byte-for-byte.
*/
readonly platform?: ProviderPrPlatform;
};

/**
* The reconnect message for a surface's connected provider. GitHub (and an
* unknown platform) keeps `prReview.connectionExpired`; the provider arms
* reuse the reconnect notice's own titles, which every catalog already
* translates, so the announced status and the visible notice agree.
*/
export function reconnectExpiredMessage(platform: ProviderPrPlatform | undefined): string {
if (platform === 'gitlab') {
return i18n.t('prReview.reconnectNotice.gitlabTitle');
}
if (platform === 'bitbucket') {
return i18n.t('prReview.reconnectNotice.bitbucketTitle');
}
return i18n.t('prReview.connectionExpired');
}

/**
* The bad-request inline copy per surface. The submit surface words the
* rejection after the connected provider when a term rides (s6f); the
Expand Down Expand Up @@ -104,7 +127,7 @@ export function mutationErrorDisplay(
return { kind: 'not-found', message: i18n.t('prReview.discussion.commentEditUnavailable') };
}
if (classification.kind === 'reconnect') {
return { kind: 'reconnect', message: i18n.t('prReview.connectionExpired') };
return { kind: 'reconnect', message: reconnectExpiredMessage(options?.platform) };
}
// The server's raw text never reaches the inline box, whatever the
// retryable carrier was. A rate limit gets the localized wait copy (the
Expand Down
Loading