From aa4423a7a15a7394fb2aee27d05fbd989f8d7e5b Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 4 May 2026 12:13:12 +0100 Subject: [PATCH] feat(code): show persistent error state for GitHub connect failures --- .../list/GitHubConnectionBanner.tsx | 21 +++-- .../hooks/useGitHubIntegrationCallback.ts | 19 +++-- .../hooks/useGithubUserConnect.ts | 78 +++++++++++++++---- .../components/GitIntegrationStep.tsx | 77 ++++++++++++------ .../sections/GitHubIntegrationSection.tsx | 2 +- 5 files changed, 148 insertions(+), 49 deletions(-) diff --git a/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx b/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx index 67c9225753..fc9e687159 100644 --- a/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx @@ -1,6 +1,9 @@ import { Button } from "@components/ui/Button"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useGithubUserConnect } from "@features/integrations/hooks/useGithubUserConnect"; +import { + describeGithubConnectError, + useGithubUserConnect, +} from "@features/integrations/hooks/useGithubUserConnect"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import { useUserRepositoryIntegration } from "@hooks/useIntegrations"; import { @@ -21,8 +24,9 @@ export function GitHubConnectionBanner() { const projectId = useAuthStateValue((s) => s.projectId); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); - const { state, connect } = useGithubUserConnect({ projectId }); + const { state, error, connect, reset } = useGithubUserConnect({ projectId }); const connecting = state === "connecting"; + const hasConnectError = state === "error"; const canConnectCloud = projectId != null && cloudRegion != null; if (loginLoading) { @@ -37,11 +41,13 @@ export function GitHubConnectionBanner() { return null; } - const label = connecting - ? "Waiting for GitHub connection to complete in browser…" - : hasGithubForProject - ? "Connect your GitHub profile to highlight what's relevant to you" - : "Connect your GitHub repo(s) to highlight what's relevant to you"; + const label = hasConnectError + ? describeGithubConnectError(error) + : connecting + ? "Waiting for GitHub connection to complete in browser…" + : hasGithubForProject + ? "Connect your GitHub profile to highlight what's relevant to you" + : "Connect your GitHub repo(s) to highlight what's relevant to you"; return (
@@ -77,6 +83,7 @@ export function GitHubConnectionBanner() { } onClick={() => { if (!canConnectCloud) return; + if (hasConnectError) reset(); void connect(); }} > diff --git a/apps/code/src/renderer/features/integrations/hooks/useGitHubIntegrationCallback.ts b/apps/code/src/renderer/features/integrations/hooks/useGitHubIntegrationCallback.ts index 7c9910af49..c0cb20a935 100644 --- a/apps/code/src/renderer/features/integrations/hooks/useGitHubIntegrationCallback.ts +++ b/apps/code/src/renderer/features/integrations/hooks/useGitHubIntegrationCallback.ts @@ -8,9 +8,14 @@ const log = logger.scope("github-integration-callback-hook"); const DEFAULT_ERROR_MESSAGE = "GitHub install failed. Please try connecting again."; +export interface IntegrationCallbackError { + message: string; + code: string | null; +} + interface Options { onSuccess: (projectId: number | null) => void; - onError: (message: string) => void; + onError: (error: IntegrationCallbackError) => void; onTimedOut?: () => void; } @@ -34,7 +39,10 @@ export function useGitHubIntegrationCallback({ onData: (data) => { log.info("Received integration deep link callback", data); if (data.status === "error") { - optsRef.current.onError(data.errorMessage ?? DEFAULT_ERROR_MESSAGE); + optsRef.current.onError({ + message: data.errorMessage ?? DEFAULT_ERROR_MESSAGE, + code: data.errorCode, + }); return; } optsRef.current.onSuccess(data.projectId); @@ -61,9 +69,10 @@ export function useGitHubIntegrationCallback({ if (!pending) return; log.info("Consumed pending integration callback on mount", pending); if (pending.status === "error") { - optsRef.current.onError( - pending.errorMessage ?? DEFAULT_ERROR_MESSAGE, - ); + optsRef.current.onError({ + message: pending.errorMessage ?? DEFAULT_ERROR_MESSAGE, + code: pending.errorCode, + }); return; } optsRef.current.onSuccess(pending.projectId); diff --git a/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts b/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts index 7314d63f1b..6023efe3fd 100644 --- a/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts +++ b/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts @@ -5,12 +5,57 @@ import { trpcClient } from "@renderer/trpc/client"; import { IS_DEV } from "@shared/constants/environment"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; const POLL_INTERVAL_MS = 3_000; const POLL_TIMEOUT_MS = 300_000; -export type GithubUserConnectState = "idle" | "connecting" | "timed-out"; +export type GithubUserConnectState = + | "idle" + | "connecting" + | "timed-out" + | "error"; + +export interface GithubUserConnectError { + message: string; + code: string | null; +} + +const ERROR_MESSAGES: Record = { + access_denied: + "You declined access on GitHub. Try again to grant the permissions PostHog Code needs.", + github_oauth_error: "GitHub returned an error during sign-in. Please retry.", + missing_params: "GitHub returned an incomplete response. Please retry.", + invalid_state: + "The connection link expired before you finished. Please retry.", + invalid_installation: + "This GitHub installation isn't reachable from your account. Try a different account or org.", + invalid_team: + "Your project access changed during sign-in. Please retry from the current project.", + invalid_installation_id: + "GitHub returned an invalid installation. Please retry.", + exchange_failed: + "Couldn't exchange the GitHub authorization code. Please retry.", + installation_verify_failed: + "Couldn't verify your access to this GitHub installation. Please retry.", + installation_not_authorized: + "Your GitHub account isn't authorized for this installation. Ask the org admin to grant access, or sign in with a different GitHub account.", + installation_fetch_failed: + "Couldn't fetch installation details from GitHub. Please retry.", + installation_token_failed: + "Couldn't get an access token from GitHub. Please retry.", + integration_create_failed: + "Couldn't save the GitHub connection. Please retry.", +}; + +export function describeGithubConnectError( + error: GithubUserConnectError | null, +): string { + if (!error) return ""; + if (error.code && ERROR_MESSAGES[error.code]) { + return ERROR_MESSAGES[error.code]; + } + return error.message; +} interface Options { projectId: number | null; @@ -18,6 +63,7 @@ interface Options { interface Result { state: GithubUserConnectState; + error: GithubUserConnectError | null; connect: () => Promise; reset: () => void; } @@ -35,6 +81,7 @@ export function useGithubUserConnect({ projectId }: Options): Result { const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const queryClient = useQueryClient(); const [state, setState] = useState("idle"); + const [error, setError] = useState(null); const stateRef = useRef(state); stateRef.current = state; const pollTimerRef = useRef | null>(null); @@ -84,12 +131,13 @@ export function useGithubUserConnect({ projectId }: Options): Result { onSuccess: (callbackProjectId) => { stopPolling(); setState("idle"); + setError(null); invalidate(callbackProjectId ?? projectId); }, - onError: (message) => { + onError: (cbError) => { stopPolling(); - setState("idle"); - toast.error(message); + setState("error"); + setError(cbError); }, onTimedOut: () => { stopPolling(); @@ -99,9 +147,10 @@ export function useGithubUserConnect({ projectId }: Options): Result { }); const connect = useCallback(async () => { - if (stateRef.current !== "idle") return; + if (stateRef.current === "connecting") return; if (!cloudRegion || projectId === null || !client) return; stopPolling(); + setError(null); setState("connecting"); try { const res = await client.startGithubUserIntegrationConnect(projectId); @@ -122,20 +171,21 @@ export function useGithubUserConnect({ projectId }: Options): Result { stopPolling(); setState("timed-out"); }, POLL_TIMEOUT_MS); - } catch (error) { - setState("idle"); - toast.error( - error instanceof Error - ? error.message - : "Failed to start GitHub connection", - ); + } catch (e) { + setState("error"); + setError({ + message: + e instanceof Error ? e.message : "Failed to start GitHub connection", + code: null, + }); } }, [client, cloudRegion, projectId, invalidate, stopPolling]); const reset = useCallback(() => { stopPolling(); + setError(null); setState("idle"); }, [stopPolling]); - return { state, connect, reset }; + return { state, error, connect, reset }; } diff --git a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx index ed5b787089..d09600ee97 100644 --- a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx @@ -1,7 +1,10 @@ import { useSelectProjectMutation } from "@features/auth/hooks/authMutations"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; -import { useGithubUserConnect } from "@features/integrations/hooks/useGithubUserConnect"; +import { + describeGithubConnectError, + useGithubUserConnect, +} from "@features/integrations/hooks/useGithubUserConnect"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { useUserGithubIntegrations, @@ -74,11 +77,23 @@ export function GitIntegrationStep({ return currentProjectId ?? projects[0]?.id ?? null; }, [manuallySelectedProjectId, currentProjectId, projects]); - const { state: connectState, connect: handleConnectGitHub } = - useGithubUserConnect({ projectId: selectedProjectId }); + const { + state: connectState, + error: connectError, + connect: handleConnectGitHub, + reset: resetConnect, + } = useGithubUserConnect({ projectId: selectedProjectId }); const isConnecting = connectState === "connecting"; const timedOut = connectState === "timed-out"; - const canTakeAction = !isConnecting && !timedOut; + const hasConnectError = connectState === "error"; + const canTakeAction = !isConnecting && !timedOut && !hasConnectError; + const defaultPanelMessage = hasConnectError + ? describeGithubConnectError(connectError) + : timedOut + ? "We didn't hear back from GitHub. If the browser tab was closed, click Connect again." + : isConnecting + ? "Waiting for GitHub..." + : "Optional. Unlocks cloud agents and pull request workflows."; const selectedProject = useMemo( () => projects.find((p) => p.id === selectedProjectId), @@ -446,25 +461,43 @@ export function GitIntegrationStep({ ) : ( - - {timedOut - ? "We didn't hear back from GitHub. If the browser tab was closed, click Connect again." - : isConnecting - ? "Waiting for GitHub..." - : "Optional. Unlocks cloud agents and pull request workflows."} - - + {defaultPanelMessage} + + + + {hasConnectError && ( + + )} + ) ) : null} diff --git a/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx b/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx index 7b498abbd3..93639605c7 100644 --- a/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx @@ -74,7 +74,7 @@ export function GitHubIntegrationSection({ setConnecting(false); invalidateIntegrations(); }, - onError: (message) => { + onError: ({ message }) => { stopPolling(); setConnecting(false); toast.error(message);