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