diff --git a/apps/mobile/src/components/pr-review/composer-inline-error.test.tsx b/apps/mobile/src/components/pr-review/composer-inline-error.test.tsx
index f2c31bc1856..b8b9bb8228c 100644
--- a/apps/mobile/src/components/pr-review/composer-inline-error.test.tsx
+++ b/apps/mobile/src/components/pr-review/composer-inline-error.test.tsx
@@ -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(() => ({
@@ -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(
+
+ {createElement(Harness, { error: revoked, isEdit: false })}
+
+ );
+ 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();
+ });
});
diff --git a/apps/mobile/src/components/pr-review/composer-inline-error.tsx b/apps/mobile/src/components/pr-review/composer-inline-error.tsx
index 16ab4584120..e60c81dea43 100644
--- a/apps/mobile/src/components/pr-review/composer-inline-error.tsx
+++ b/apps/mobile/src/components/pr-review/composer-inline-error.tsx
@@ -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'
@@ -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) {
@@ -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.
diff --git a/apps/mobile/src/components/pr-review/discussion/pr-comment-edit-sheet.test.tsx b/apps/mobile/src/components/pr-review/discussion/pr-comment-edit-sheet.test.tsx
index 11dff61aa87..ef48b5b36b3 100644
--- a/apps/mobile/src/components/pr-review/discussion/pr-comment-edit-sheet.test.tsx
+++ b/apps/mobile/src/components/pr-review/discussion/pr-comment-edit-sheet.test.tsx
@@ -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';
@@ -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();
+ return { ...actual, useProviderPrScopeOrNull: () => null };
+});
+
const baseProps = {
owner: 'octocat',
repo: 'hello',
diff --git a/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.tsx b/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.tsx
index 9af78be1338..eb45d74808f 100644
--- a/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.tsx
+++ b/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.tsx
@@ -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,
@@ -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.
@@ -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
@@ -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;
diff --git a/apps/mobile/src/components/pr-review/discussion/reply-input.tsx b/apps/mobile/src/components/pr-review/discussion/reply-input.tsx
index 85de0d3428e..5de8edd0a0a 100644
--- a/apps/mobile/src/components/pr-review/discussion/reply-input.tsx
+++ b/apps/mobile/src/components/pr-review/discussion/reply-input.tsx
@@ -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,
@@ -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,
diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx
index 2342791d1f9..8bb0620f87f 100644
--- a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx
+++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx
@@ -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));
@@ -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;
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 44df3be0c45..fec4cfcf183 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
@@ -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,
@@ -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(
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 1610fb85f46..fa72d474861 100644
--- a/apps/mobile/src/components/pr-review/pr-review-submit.tsx
+++ b/apps/mobile/src/components/pr-review/pr-review-submit.tsx
@@ -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);
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 a980babec92..c64285e88e7 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
@@ -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',
+ });
+ });
});
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 a794e9103b6..958622c9e1c 100644
--- a/apps/mobile/src/lib/pr-review/mutation-error-display.ts
+++ b/apps/mobile/src/lib/pr-review/mutation-error-display.ts
@@ -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,
@@ -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
@@ -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