From 3bef6458a0231870f55ca9e97ae5c31cb0b128d8 Mon Sep 17 00:00:00 2001
From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com>
Date: Tue, 27 Oct 2026 05:15:07 +0000
Subject: [PATCH] fix: recover source-control provider status reads
---
.../SourceControl.recovery.client.test.tsx | 190 ++++++++++++++++++
.../src/components/settings/SourceControl.tsx | 44 +++-
2 files changed, 228 insertions(+), 6 deletions(-)
create mode 100644 apps/web/src/components/settings/SourceControl.recovery.client.test.tsx
diff --git a/apps/web/src/components/settings/SourceControl.recovery.client.test.tsx b/apps/web/src/components/settings/SourceControl.recovery.client.test.tsx
new file mode 100644
index 0000000000..00d716fd9f
--- /dev/null
+++ b/apps/web/src/components/settings/SourceControl.recovery.client.test.tsx
@@ -0,0 +1,190 @@
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import {
+ QueryClient,
+ QueryClientProvider,
+ useQuery,
+} from '@tanstack/react-query';
+import type { SetupSourceControlStatus } from '@roomote/types';
+
+import { SourceControl } from './SourceControl';
+
+const { loadStatus, mutate } = vi.hoisted(() => ({
+ loadStatus: vi.fn(),
+ mutate: vi.fn(),
+}));
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/settings/source-control',
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+vi.mock('@/hooks/useUser', () => ({
+ useAuthorizedUser: () => ({ isAdmin: true }),
+}));
+
+vi.mock('@/hooks/github', () => ({
+ useGitHubInstallations: () => ({ data: [], isPending: false }),
+ useEnableGitHubApp: () => ({ mutate, isPending: false }),
+ useSyncGitHubInstallations: () => ({ mutate, isPending: false }),
+ useCreateGitHubAppManifest: () => ({ mutate, isPending: false }),
+}));
+
+vi.mock('@/hooks/source-control', () => ({
+ useSourceControlConfigStatus: () =>
+ useQuery({ queryKey: ['provider-status'], queryFn: loadStatus }),
+ useRepositories: () => ({ data: [], isPending: false }),
+ useSyncRepositories: () => ({ mutate, isPending: false }),
+ usePrAction: () => ({ data: { prAction: 'draft' }, isLoading: false }),
+ useSetPrAction: () => ({ mutate, isPending: false }),
+ useMarkRoomotePrReadyAfterCleanReview: () => ({
+ data: { enabled: false },
+ isLoading: false,
+ }),
+ useSetMarkRoomotePrReadyAfterCleanReview: () => ({
+ mutate,
+ isPending: false,
+ }),
+ useGitHubRoomoteMention: () => ({
+ data: { enabled: true },
+ isLoading: false,
+ }),
+ useSetGitHubRoomoteMention: () => ({ mutate, isPending: false }),
+}));
+
+vi.mock('./SourceControlConfigForm', () => ({
+ SourceControlConfigForm: ({ provider }: { provider: string }) => (
+
+ ),
+}));
+
+const status: SetupSourceControlStatus = {
+ selectedProvider: null,
+ preselectedProvider: 'github',
+ runtimeConfiguredProvider: null,
+ runtimeConfiguredProviders: [],
+ lockReason: null,
+ connectedProvider: null,
+ providers: [
+ {
+ provider: 'gitlab',
+ label: 'GitLab',
+ connectionMode: 'token',
+ fields: [],
+ runtimeConfigSatisfied: false,
+ savedConfigSatisfied: true,
+ configSatisfied: true,
+ configStepSatisfied: true,
+ configSatisfiedByRuntimeEnv: false,
+ connected: false,
+ repositoryCount: 0,
+ },
+ ],
+ setupSatisfied: false,
+ setupSatisfiedByRuntimeEnv: false,
+};
+
+function renderSettings() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: Infinity } },
+ });
+ render(
+
+
+ ,
+ );
+ return client;
+}
+
+describe('source-control provider status recovery', () => {
+ beforeEach(() => {
+ loadStatus.mockReset().mockResolvedValue(status);
+ mutate.mockReset();
+ });
+
+ it('does not offer setup from an unavailable status and retries the actual query', async () => {
+ loadStatus.mockRejectedValueOnce(new Error('Read unavailable'));
+ renderSettings();
+
+ expect(
+ await screen.findByText('Failed to load source control provider status.'),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Set it up for/ })).toBeNull();
+ expect(screen.queryByLabelText('gitlab draft')).toBeNull();
+ expect(
+ screen.getByRole('combobox', { name: 'Pull request delivery' }),
+ ).toBeEnabled();
+
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Retry provider status' }),
+ );
+
+ expect(await screen.findByLabelText('gitlab draft')).toBeInTheDocument();
+ expect(loadStatus).toHaveBeenCalledTimes(2);
+ expect(mutate).not.toHaveBeenCalled();
+ });
+
+ it('keeps failed retries recoverable and prevents duplicate requests while pending', async () => {
+ loadStatus.mockRejectedValue(new Error('Read unavailable'));
+ renderSettings();
+ const retry = await screen.findByRole('button', {
+ name: 'Retry provider status',
+ });
+
+ let reject!: (error: Error) => void;
+ loadStatus.mockImplementationOnce(
+ () =>
+ new Promise((_, fail) => {
+ reject = fail;
+ }),
+ );
+ fireEvent.click(retry);
+ await waitFor(() => expect(loadStatus).toHaveBeenCalledTimes(2));
+ expect(screen.queryByRole('button', { name: /Set it up for/ })).toBeNull();
+ expect(
+ screen.queryByRole('button', { name: 'Retry provider status' }),
+ ).not.toBeEnabled();
+
+ await act(async () => reject(new Error('Still unavailable')));
+ expect(
+ await screen.findByRole('button', { name: 'Retry provider status' }),
+ ).toBeEnabled();
+ loadStatus.mockResolvedValue(status);
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Retry provider status' }),
+ );
+ expect(await screen.findByLabelText('gitlab draft')).toBeInTheDocument();
+ expect(loadStatus).toHaveBeenCalledTimes(3);
+ });
+
+ it('preserves the mounted provider form and draft through a failed background read and Retry', async () => {
+ const client = renderSettings();
+ const draft = await screen.findByLabelText('gitlab draft');
+ fireEvent.change(draft, { target: { value: 'unsaved local edit' } });
+
+ loadStatus.mockRejectedValueOnce(new Error('Refresh unavailable'));
+ await act(async () => {
+ await client.refetchQueries({ queryKey: ['provider-status'] });
+ });
+ const retry = await screen.findByRole('button', {
+ name: 'Retry provider status',
+ });
+ expect(screen.getByLabelText('gitlab draft')).toBe(draft);
+ expect(draft).toHaveValue('unsaved local edit');
+ fireEvent.click(retry);
+ await waitFor(() => expect(loadStatus).toHaveBeenCalledTimes(3));
+ await waitFor(() =>
+ expect(
+ screen.queryByRole('button', { name: 'Retry provider status' }),
+ ).toBeNull(),
+ );
+ expect(screen.getByLabelText('gitlab draft')).toBe(draft);
+ expect(draft).toHaveValue('unsaved local edit');
+ expect(mutate).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/components/settings/SourceControl.tsx b/apps/web/src/components/settings/SourceControl.tsx
index c647b4e218..2767c729cd 100644
--- a/apps/web/src/components/settings/SourceControl.tsx
+++ b/apps/web/src/components/settings/SourceControl.tsx
@@ -52,6 +52,7 @@ import {
SelectTrigger,
SelectValue,
Sparkles,
+ Skeleton,
Spinner,
Switch,
} from '@/components/system';
@@ -191,6 +192,7 @@ export function SourceControl() {
const gitHubInstallations = useGitHubInstallations();
const sourceControlConfigStatus = useSourceControlConfigStatus();
+ const [retryingProviderStatus, setRetryingProviderStatus] = useState(false);
const gitHubRepositories = useRepositories({
sourceControlProvider: 'github',
});
@@ -465,12 +467,42 @@ export function SourceControl() {
{isAdmin ? : null}
- {providerBlocks.map((providerBlock) => (
-
- ))}
+ {sourceControlConfigStatus.isError || retryingProviderStatus ? (
+
+
+ Failed to load source control provider status.
+
+
+
+ ) : null}
+ {sourceControlConfigStatus.data
+ ? providerBlocks.map((providerBlock) => (
+
+ ))
+ : sourceControlConfigStatus.isPending && !retryingProviderStatus
+ ? providerBlocks.map(({ provider }) => (
+
+ ))
+ : null}
);
}