From d45bf50d44b7f5ac97e2415b8858d15ca27bd04d Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:58:30 +0000 Subject: [PATCH] fix(web): recover initial automation settings reads --- ...AutomationsSettings.render.client.test.tsx | 113 ++++++++++++++++++ .../automations/AutomationsSettings.tsx | 37 +++++- 2 files changed, 148 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx index 95829df36..770b48759 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx @@ -50,6 +50,7 @@ const state = vi.hoisted(() => ({ realWebhookQuery: null as | null | (() => Promise<{ enabled: boolean; url: string | null }>), + realSettingsQuery: null as null | (() => Promise), builtInAutomationWebhookPending: false, builtInAutomationWebhookError: false, builtInAutomationWebhookFetching: false, @@ -414,6 +415,13 @@ vi.mock('@tanstack/react-query', () => ({ useQuery: (queryOptions: { queryKey?: unknown[] }) => { state.queriedKeys.push(queryOptions.queryKey); const key1 = queryOptions.queryKey?.[1]; + if (key1 === 'getSettings' && state.realSettingsQuery) { + return actualQuery.useQuery({ + queryKey: queryOptions.queryKey!, + queryFn: state.realSettingsQuery, + retry: false, + }); + } if (key1 === 'getCustomAutomationOptions') { const managerSlackChannelId = state.settingsQuery.data.settings.managerSlackChannelId; @@ -1078,6 +1086,7 @@ describe('AutomationsSettings', () => { state.customAutomationWebhookSettings = null; state.builtInAutomationWebhookFallback = { enabled: false, url: null }; state.realWebhookQuery = null; + state.realSettingsQuery = null; state.builtInAutomationWebhookRefetch.mockResolvedValue(undefined); state.builtInAutomationWebhookPending = false; state.builtInAutomationWebhookError = false; @@ -1231,6 +1240,110 @@ describe('AutomationsSettings', () => { ); }); + it('recovers a failed initial settings read with a stable Retry through real query transitions', async () => { + const data = state.settingsQuery.data; + let finishRetry!: (value: typeof data) => void; + const query = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('initial failure')) + .mockRejectedValueOnce(new Error('still unavailable')) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishRetry = resolve; + }), + ); + state.realSettingsQuery = query; + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + render( + + + , + ); + + const retry = await screen.findByRole('button', { + name: 'Retry loading built-in automation settings', + }); + expect(screen.getByRole('alert')).toHaveTextContent( + 'Could not load built-in automation settings.', + ); + expect( + screen.queryByRole('button', { + name: /(?:Set up|Configure) Manager Stats/, + }), + ).not.toBeInTheDocument(); + // The independent custom-automation journey remains available. + expect(screen.getByRole('button', { name: 'New' })).toBeEnabled(); + fireEvent.click(retry); + await waitFor(() => expect(query).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(retry).toBeEnabled()); + fireEvent.click(retry); + await waitFor(() => expect(query).toHaveBeenCalledTimes(3)); + expect( + screen.getByRole('button', { + name: 'Retry loading built-in automation settings', + }), + ).toBe(retry); + expect(retry).toBeDisabled(); + expect(retry).toHaveAttribute('aria-busy', 'true'); + fireEvent.click(retry); + expect(query).toHaveBeenCalledTimes(3); + expect(screen.getByRole('alert')).toHaveTextContent( + 'Could not load built-in automation settings.', + ); + + await act(async () => finishRetry(data)); + expect( + await screen.findByRole('button', { + name: /(?:Set up|Configure) Manager Stats/, + }), + ).toBeEnabled(); + expect( + screen.queryByText('Could not load built-in automation settings.'), + ).not.toBeInTheDocument(); + expect(screen.getByText(/^Default destination:/)).toBeInTheDocument(); + client.clear(); + }); + + it('keeps cached settings and an open instruction draft after a background read fails', async () => { + state.settingsQuery.data.reviewer.enabled = true; + state.settingsQuery.data.settings.reviewer.enabled = true; + const query = vi + .fn() + .mockResolvedValueOnce(state.settingsQuery.data) + .mockRejectedValueOnce(new Error('refresh failed')); + state.realSettingsQuery = query; + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + render( + + + , + ); + await screen.findByRole('button', { + name: /(?:Set up|Configure) Review Code/, + }); + await openReviewerCard(); + const input = screen.getByLabelText('Additional instructions'); + fireEvent.change(input, { target: { value: 'Keep this review draft.' } }); + + await act(async () => + client.refetchQueries({ queryKey: ['automations', 'getSettings'] }), + ); + expect(query).toHaveBeenCalledTimes(2); + expect(input).toHaveValue('Keep this review draft.'); + expect( + screen.getByRole('dialog', { name: 'Review Code' }), + ).toBeInTheDocument(); + expect( + screen.queryByText('Could not load built-in automation settings.'), + ).not.toBeInTheDocument(); + client.clear(); + }); + it('explains that GitHub controls whether the review check is required', async () => { state.settingsQuery.data.reviewer.enabled = true; state.settingsQuery.data.settings.reviewer.enabled = true; diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.tsx index 7b47203a7..7fb9d274c 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx @@ -1927,6 +1927,7 @@ export function AutomationsSettings({ const [formState, setFormState] = useState(null); const [savedState, setSavedState] = useState(null); const [fieldErrors, setFieldErrors] = useState({}); + const [retryingInitialSettings, setRetryingInitialSettings] = useState(false); const [slackChannelAccessWarnings, setSlackChannelAccessWarnings] = useState(EMPTY_SLACK_CHANNEL_ACCESS_WARNINGS); const [managerSlackChannelId, setManagerSlackChannelId] = useState< @@ -1963,6 +1964,16 @@ export function AutomationsSettings({ }); const settingsQuery = useQuery(trpc.automations.getSettings.queryOptions()); + const hasInitialSettingsError = + settingsQuery.data === undefined && + (settingsQuery.isError || retryingInitialSettings); + const retryInitialSettings = () => { + if (settingsQuery.isFetching || retryingInitialSettings) return; + setRetryingInitialSettings(true); + void settingsQuery + .refetch() + .finally(() => setRetryingInitialSettings(false)); + }; const commsStatus = useQuery(trpc.comms.status.queryOptions()); const slackChannelsQuery = useQuery( trpc.automations.listSlackChannels.queryOptions(undefined, { @@ -3238,6 +3249,26 @@ export function AutomationsSettings({ ) as Record; return (
+ {hasInitialSettingsError ? ( +
+

+ Could not load built-in automation settings. +

+ +
+ ) : null} {!settingsQuery.isPending && capabilities?.requiresSlackReconnect && capabilities.missingScopes.length > 0 ? ( @@ -3290,7 +3321,8 @@ export function AutomationsSettings({
{toolbarLeading}
- {settingsQuery.isPending || !formState ? ( + {hasInitialSettingsError ? null : settingsQuery.isPending || + !formState ? ( ) : ( } > - {settingsQuery.isPending || !formState ? ( + {hasInitialSettingsError ? null : settingsQuery.isPending || + !formState ? ( ) : visibleBuiltInAutomations.size === 0 && !(