Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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) {
Expand All @@ -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 (
<div className="pointer-events-auto absolute inset-x-2 bottom-2 z-60">
Expand Down Expand Up @@ -77,6 +83,7 @@ export function GitHubConnectionBanner() {
}
onClick={() => {
if (!canConnectCloud) return;
if (hasConnectError) reset();
void connect();
}}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,65 @@ 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<string, string> = {
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;
}

interface Result {
state: GithubUserConnectState;
error: GithubUserConnectError | null;
connect: () => Promise<void>;
reset: () => void;
}
Expand All @@ -35,6 +81,7 @@ export function useGithubUserConnect({ projectId }: Options): Result {
const cloudRegion = useAuthStateValue((s) => s.cloudRegion);
const queryClient = useQueryClient();
const [state, setState] = useState<GithubUserConnectState>("idle");
const [error, setError] = useState<GithubUserConnectError | null>(null);
const stateRef = useRef(state);
stateRef.current = state;
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand All @@ -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 };
}
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -446,25 +461,43 @@ export function GitIntegrationStep({
</Flex>
) : (
<Flex direction="column" gap="3">
<Text className="text-(--gray-11) text-sm">
{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."}
</Text>
<Button
size="1"
variant="soft"
onClick={() => void handleConnectGitHub()}
loading={isConnecting}
className="self-start"
<Text
className={
hasConnectError
? "text-(--red-11) text-sm"
: "text-(--gray-11) text-sm"
}
>
{isConnecting
? "Retry connection"
: "Connect GitHub"}
<ArrowSquareOut size={12} />
</Button>
{defaultPanelMessage}
</Text>
<Flex gap="2">
<Button
size="1"
variant="soft"
onClick={() => {
if (hasConnectError) resetConnect();
void handleConnectGitHub();
}}
loading={isConnecting}
>
{isConnecting
? "Retry connection"
: hasConnectError || timedOut
? "Try again"
: "Connect GitHub"}
<ArrowSquareOut size={12} />
</Button>
{hasConnectError && (
<Button
size="1"
variant="ghost"
color="gray"
onClick={resetConnect}
>
Dismiss
</Button>
)}
</Flex>
</Flex>
)
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function GitHubIntegrationSection({
setConnecting(false);
invalidateIntegrations();
},
onError: (message) => {
onError: ({ message }) => {
stopPolling();
setConnecting(false);
toast.error(message);
Expand Down
Loading