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