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,6 @@
import { Button } from "@components/ui/Button";
import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient";
import { useAuthStateValue } from "@features/auth/hooks/authQueries";
import { useGithubUserConnect } from "@features/integrations/hooks/useGithubUserConnect";
import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery";
import { useUserRepositoryIntegration } from "@hooks/useIntegrations";
import {
Expand All @@ -9,18 +9,6 @@ import {
InfoIcon,
} from "@phosphor-icons/react";
import { Spinner } from "@radix-ui/themes";
import { trpcClient } from "@renderer/trpc/client";
import { queryClient } from "@utils/queryClient";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";

async function openUrlInBrowser(url: string): Promise<void> {
try {
await trpcClient.os.openExternal.mutate({ url });
} catch {
window.open(url, "_blank", "noopener,noreferrer");
}
}

export function GitHubConnectionBanner() {
const { data: githubLogin, isLoading: loginLoading } = useAuthenticatedQuery(
Expand All @@ -30,33 +18,12 @@ export function GitHubConnectionBanner() {
);
const { hasGithubIntegration: hasGithubForProject } =
useUserRepositoryIntegration();
const apiClient = useOptionalAuthenticatedClient();
const projectId = useAuthStateValue((s) => s.projectId);
const cloudRegion = useAuthStateValue((s) => s.cloudRegion);
const awaitingLink = useRef(false);
const connectInFlight = useRef(false);
const [connecting, setConnecting] = useState(false);

const canConnectCloud =
apiClient != null && projectId != null && cloudRegion != null;

// After the user clicks connect and returns to the app, refetch to pick up the new github_login
useEffect(() => {
const onFocus = () => {
if (awaitingLink.current) {
awaitingLink.current = false;
void queryClient.invalidateQueries({ queryKey: ["github_login"] });
void queryClient.invalidateQueries({
queryKey: ["integrations", "list"],
});
void queryClient.invalidateQueries({
queryKey: ["user-github-integrations"],
});
}
};
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, []);
const { state, connect } = useGithubUserConnect({ projectId });
const connecting = state === "connecting";
const canConnectCloud = projectId != null && cloudRegion != null;

if (loginLoading) {
return null;
Expand Down Expand Up @@ -109,37 +76,8 @@ export function GitHubConnectionBanner() {
</>
}
onClick={() => {
if (!canConnectCloud || connectInFlight.current) {
return;
}
connectInFlight.current = true;
awaitingLink.current = true;
setConnecting(true);
void (async () => {
try {
const res =
await apiClient.startGithubUserIntegrationConnect(projectId);
const installUrl = res.install_url?.trim() ?? "";
if (!installUrl) {
awaitingLink.current = false;
toast.error(
"GitHub connection did not return a URL. Please try again.",
);
return;
}
await openUrlInBrowser(installUrl);
} catch (e) {
awaitingLink.current = false;
toast.error(
e instanceof Error
? e.message
: "Failed to start GitHub connection",
);
} finally {
connectInFlight.current = false;
setConnecting(false);
}
})();
if (!canConnectCloud) return;
void connect();
}}
>
{connecting ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient";
import { useAuthStateValue } from "@features/auth/hooks/authQueries";
import { useGitHubIntegrationCallback } from "@features/integrations/hooks/useGitHubIntegrationCallback";
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";

interface Options {
projectId: number | null;
}

interface Result {
state: GithubUserConnectState;
connect: () => Promise<void>;
reset: () => void;
}

async function openUrlInBrowser(url: string): Promise<void> {
try {
await trpcClient.os.openExternal.mutate({ url });
} catch {
window.open(url, "_blank", "noopener,noreferrer");
}
}

export function useGithubUserConnect({ projectId }: Options): Result {
const client = useOptionalAuthenticatedClient();
const cloudRegion = useAuthStateValue((s) => s.cloudRegion);
const queryClient = useQueryClient();
const [state, setState] = useState<GithubUserConnectState>("idle");
const stateRef = useRef(state);
stateRef.current = state;
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const stopPolling = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
if (pollTimeoutRef.current) {
clearTimeout(pollTimeoutRef.current);
pollTimeoutRef.current = null;
}
}, []);

const invalidate = useCallback(
(pid: number | null) => {
if (pid !== null) {
void queryClient.invalidateQueries({
queryKey: ["integrations", pid],
});
}
void queryClient.invalidateQueries({
queryKey: ["integrations", "list"],
});
void queryClient.invalidateQueries({
queryKey: ["user-github-integrations"],
});
void queryClient.invalidateQueries({ queryKey: ["github_login"] });
},
[queryClient],
);

useEffect(() => stopPolling, [stopPolling]);

// Window-focus fallback: deep link from PostHog Cloud may not fire reliably,
// so refetch when the user returns to the app while a connect is in flight.
useEffect(() => {
if (state !== "connecting") return;
const onFocus = () => invalidate(projectId);
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [state, projectId, invalidate]);

useGitHubIntegrationCallback({
onSuccess: (callbackProjectId) => {
stopPolling();
setState("idle");
invalidate(callbackProjectId ?? projectId);
},
onError: (message) => {
stopPolling();
setState("idle");
toast.error(message);
},
Comment on lines +89 to +93

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 toast.error fires unconditionally regardless of connection state

onError calls toast.error(message) even when state === "idle". For GitHubConnectionBanner — which is mounted in the persistent inbox UI — this means that if a stale pending error callback is consumed on mount via consumePendingCallback (e.g. left over from a previous connect attempt in another session), the user will see an unexpected error toast with no obvious cause. The original banner didn't use useGitHubIntegrationCallback at all, so this is a new behaviour for that consumer.

Guard the toast (and optionally the full onError body) behind a state check:

onError: (message) => {
  stopPolling();
  if (stateRef.current === "connecting") {
    toast.error(message);
  }
  setState("idle");
},
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts
Line: 89-93

Comment:
**`toast.error` fires unconditionally regardless of connection state**

`onError` calls `toast.error(message)` even when `state === "idle"`. For `GitHubConnectionBanner` — which is mounted in the persistent inbox UI — this means that if a stale pending error callback is consumed on mount via `consumePendingCallback` (e.g. left over from a previous connect attempt in another session), the user will see an unexpected error toast with no obvious cause. The original banner didn't use `useGitHubIntegrationCallback` at all, so this is a new behaviour for that consumer.

Guard the toast (and optionally the full `onError` body) behind a state check:

```typescript
onError: (message) => {
  stopPolling();
  if (stateRef.current === "connecting") {
    toast.error(message);
  }
  setState("idle");
},
```

How can I resolve this? If you propose a fix, please make it concise.

onTimedOut: () => {
stopPolling();
setState("timed-out");
invalidate(projectId);
},
});

const connect = useCallback(async () => {
if (stateRef.current !== "idle") return;
if (!cloudRegion || projectId === null || !client) return;
stopPolling();
setState("connecting");
try {
const res = await client.startGithubUserIntegrationConnect(projectId);
const installUrl = res.install_url?.trim() ?? "";
if (!installUrl) {
throw new Error("GitHub connection did not return a URL");
}
await openUrlInBrowser(installUrl);

if (IS_DEV) {
pollTimerRef.current = setInterval(
() => invalidate(projectId),
POLL_INTERVAL_MS,
);
}

pollTimeoutRef.current = setTimeout(() => {
stopPolling();
setState("timed-out");
}, POLL_TIMEOUT_MS);
} catch (error) {
setState("idle");
toast.error(
error instanceof Error
? error.message
: "Failed to start GitHub connection",
);
}
}, [client, cloudRegion, projectId, invalidate, stopPolling]);

const reset = useCallback(() => {
stopPolling();
setState("idle");
}, [stopPolling]);

return { state, connect, reset };
}
Loading
Loading