From f07dee13879ce8a69a8f82b7a978c0a9ba93609a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?=
<4062236+iscekic@users.noreply.github.com>
Date: Wed, 21 Oct 2026 15:57:31 +0000
Subject: [PATCH] fix(mobile): name the connected provider in PR review
reconnect errors
The reconnect copy selected for every PR-review mutation failure now names
the provider of the surface it appears on: GitHub keeps
prReview.connectionExpired, while the GitLab and Bitbucket arms reuse the
reconnect notice's own provider titles, so the status line and the notice
agree.
The selection moves into reconnectExpiredMessage() plus an optional
platform on mutationErrorDisplay. useComposerInlineError reads the
provider scope the pr-review routes publish, and the merge sheet, the
review-submit sheet, the reply input and the conversation composer pass
the platform they already hold.
---
.../pr-review/composer-inline-error.test.tsx | 28 +++++++++++++
.../pr-review/composer-inline-error.tsx | 9 ++++-
.../discussion/pr-comment-edit-sheet.test.tsx | 9 +++++
.../pr-conversation-comment-composer.tsx | 8 +++-
.../pr-review/discussion/reply-input.tsx | 3 +-
.../pr-review/merge/pr-merge-sheet.test.tsx | 39 +++++++++++++++++++
.../pr-review/merge/pr-merge-sheet.tsx | 5 ++-
.../components/pr-review/pr-review-submit.tsx | 1 +
.../pr-review/mutation-error-display.test.ts | 27 +++++++++++++
.../lib/pr-review/mutation-error-display.ts | 25 +++++++++++-
10 files changed, 147 insertions(+), 7 deletions(-)
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