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
Expand Up @@ -22,6 +22,7 @@ import {
FolderOpen,
GearSix,
GitBranch,
Plus,
} from "@phosphor-icons/react";
import {
AlertDialog,
Expand Down Expand Up @@ -110,12 +111,11 @@ export function GitIntegrationStep({
isLoading: githubUserIntegrationsLoading,
} = useUserGithubIntegrations();
const hasGitIntegration = githubUserIntegrations.length > 0;
const { repositories, isLoadingRepos, failedInstallationIds } =
const { repositories, failedInstallationIds, reposByInstallationId } =
useUserRepositoryIntegration();
const githubIntegration = githubUserIntegrations[0] ?? null;
const integrationIsStale =
!!githubIntegration &&
failedInstallationIds.includes(githubIntegration.installation_id);
const anyIntegrationStale = githubUserIntegrations.some((i) =>
failedInstallationIds.includes(i.installation_id),
);

const alternativeConnectedProjects = useMemo(() => {
if (hasGitIntegration) return [];
Expand All @@ -136,13 +136,6 @@ export function GitIntegrationStep({
);
}, [alternativeConnectedProjects, selectedAlternativeId]);

const repoSummary = useMemo(() => {
if (repositories.length === 0) return null;
const names = repositories.map((r) => r.split("/").pop() ?? r);
if (names.length <= 2) return names.join(" and ");
return `${names[0]}, ${names[1]} and ${names.length - 2} more`;
}, [repositories]);

const repoMatchesGitHub = useMemo(() => {
if (!detectedRepo || repositories.length === 0) return false;
return repositories.some(
Expand All @@ -151,18 +144,23 @@ export function GitIntegrationStep({
}, [detectedRepo, repositories]);

const apiClient = useOptionalAuthenticatedClient();
const [disconnectConfirmOpen, setDisconnectConfirmOpen] = useState(false);
const [disconnectTarget, setDisconnectTarget] = useState<{
installationId: string;
accountName: string;
} | null>(null);
const [reconnectingInstallationId, setReconnectingInstallationId] = useState<
string | null
>(null);
const disconnectMutation = useMutation({
mutationFn: async (opts: { silent?: boolean } = {}) => {
const installationId = githubIntegration?.installation_id;
if (!apiClient || !installationId) {
throw new Error("No GitHub integration to disconnect");
mutationFn: async (opts: { installationId: string; silent?: boolean }) => {
if (!apiClient) {
throw new Error("Not authenticated");
}
await apiClient.disconnectGithubUserIntegration(installationId);
await apiClient.disconnectGithubUserIntegration(opts.installationId);
return { silent: opts.silent ?? false };
},
onSuccess: ({ silent }) => {
setDisconnectConfirmOpen(false);
setDisconnectTarget(null);
invalidateGithubQueries(queryClient, selectedProjectId);
if (!silent) toast.success("GitHub disconnected.");
},
Expand Down Expand Up @@ -344,7 +342,7 @@ export function GitIntegrationStep({
{isLoading || githubUserIntegrationsLoading ? (
<Skeleton className="h-[16px] w-[80px]" />
) : hasGitIntegration ? (
integrationIsStale ? (
anyIntegrationStale ? (
<Text className="text-(--amber-11) text-[13px]">
Reconnect needed
</Text>
Expand All @@ -356,72 +354,136 @@ export function GitIntegrationStep({
className="text-(--green-9)"
/>
<Text className="text-(--green-11) text-[13px]">
Connected
{githubUserIntegrations.length > 1
? `Connected (${githubUserIntegrations.length})`
: "Connected"}
</Text>
</Flex>
)
) : null}
</Flex>
{hasGitIntegration ? (
<Flex direction="column" gap="3">
<Text
className={
integrationIsStale
? "text-(--amber-11) text-sm"
: "text-(--gray-11) text-sm"
}
>
{integrationIsStale
? "We can't reach GitHub with your saved authorization. The app may have been uninstalled or the token revoked. Reconnect to restore access."
: isLoadingRepos
? "Loading repositories..."
: repoSummary
? `Access to ${repoSummary}`
: "No repositories found. Check your GitHub app settings."}
</Text>
<Flex align="center" gap="3" wrap="wrap">
{integrationIsStale && (
<Button
size="1"
variant="solid"
loading={
disconnectMutation.isPending || isConnecting
}
onClick={async () => {
if (!githubIntegration?.installation_id) return;
try {
await disconnectMutation.mutateAsync({
silent: true,
});
} catch {
return;
}
void handleConnectGitHub();
}}
{githubUserIntegrations.map((integration) => {
const installationId = integration.installation_id;
const accountName =
integration.account?.name ?? "GitHub";
const installRepos =
reposByInstallationId[installationId];
const isLoadingInstallRepos =
installRepos === undefined;
const isStale =
failedInstallationIds.includes(installationId);
const isReconnecting =
reconnectingInstallationId === installationId;
return (
<Flex
key={integration.id}
direction="column"
gap="2"
p="3"
className="rounded-[8px] border border-(--gray-a3)"
>
Reconnect
<ArrowSquareOut size={12} />
</Button>
)}
<Button
size="1"
variant="soft"
color="gray"
onClick={() => {
const id = githubIntegration?.installation_id;
const account = githubIntegration?.account;
const url = id
? account?.type === "Organization" &&
account.name
? `https://github.com/organizations/${account.name}/settings/installations/${id}`
: `https://github.com/settings/installations/${id}`
: "https://github.com/settings/installations";
trpcClient.os.openExternal.mutate({ url });
}}
>
<GearSix size={12} />
Settings
</Button>
<Flex
align="center"
justify="between"
gap="2"
wrap="wrap"
>
<Flex align="center" gap="2">
<Text className="font-bold text-(--gray-12) text-sm">
{accountName}
</Text>
<Text className="text-(--gray-10) text-[12px]">
{integration.account?.type ===
"Organization"
? "org"
: "personal"}
</Text>
</Flex>
{isStale ? (
<Text className="text-(--amber-11) text-[12px]">
Reconnect needed
</Text>
) : (
<Text className="text-(--gray-10) text-[12px]">
{isLoadingInstallRepos
? "Loading…"
: installRepos.length === 1
? "1 repo"
: `${installRepos.length} repos`}
</Text>
)}
</Flex>
<Flex align="center" gap="3" wrap="wrap">
{isStale && (
<Button
size="1"
variant="solid"
loading={isReconnecting}
disabled={
reconnectingInstallationId !== null &&
!isReconnecting
}
onClick={async () => {
setReconnectingInstallationId(
installationId,
);
try {
await disconnectMutation.mutateAsync({
installationId,
silent: true,
});
} catch {
setReconnectingInstallationId(null);
return;
}
try {
await handleConnectGitHub();
} finally {
setReconnectingInstallationId(null);
}
}}
>
Reconnect
<ArrowSquareOut size={12} />
</Button>
)}
<Button
size="1"
variant="soft"
color="gray"
onClick={() => {
const account = integration.account;
const url =
account?.type === "Organization" &&
account.name
? `https://github.com/organizations/${account.name}/settings/installations/${installationId}`
Comment on lines +451 to +461

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 Shared isConnecting loading state across all Reconnect buttons

isConnecting is global to the component. When a user clicks "Reconnect" on one stale installation and handleConnectGitHub() starts, all other "Reconnect" buttons will also enter a loading state simultaneously. If a user has two stale integrations, clicking Reconnect on one will visually indicate both are in-progress.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx
Line: 440-450

Comment:
**Shared `isConnecting` loading state across all Reconnect buttons**

`isConnecting` is global to the component. When a user clicks "Reconnect" on one stale installation and `handleConnectGitHub()` starts, all other "Reconnect" buttons will also enter a loading state simultaneously. If a user has two stale integrations, clicking Reconnect on one will visually indicate both are in-progress.

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

: `https://github.com/settings/installations/${installationId}`;
trpcClient.os.openExternal.mutate({ url });
}}
>
<GearSix size={12} />
Settings
</Button>
<Button
size="1"
variant="soft"
color="red"
onClick={() =>
setDisconnectTarget({
installationId,
accountName,
})
}
>
Disconnect
</Button>
</Flex>
</Flex>
);
})}
<Flex align="center" gap="3" wrap="wrap">
<Button
size="1"
variant="soft"
Expand All @@ -441,11 +503,12 @@ export function GitIntegrationStep({
<Button
size="1"
variant="ghost"
color="red"
disabled={!githubIntegration?.installation_id}
onClick={() => setDisconnectConfirmOpen(true)}
color="gray"
onClick={() => void handleConnectGitHub()}
loading={isConnecting}
>
Disconnect
<Plus size={12} />
Add another GitHub org
</Button>
</Flex>
</Flex>
Expand Down Expand Up @@ -609,15 +672,18 @@ export function GitIntegrationStep({
</StepActions>

<AlertDialog.Root
open={disconnectConfirmOpen}
open={disconnectTarget !== null}
onOpenChange={(next) => {
if (!next && !disconnectMutation.isPending) {
setDisconnectConfirmOpen(false);
setDisconnectTarget(null);
}
}}
>
<AlertDialog.Content maxWidth="450px">
<AlertDialog.Title>Disconnect GitHub</AlertDialog.Title>
<AlertDialog.Title>
Disconnect{" "}
{disconnectTarget ? disconnectTarget.accountName : "GitHub"}
</AlertDialog.Title>
<AlertDialog.Description className="text-sm">
This removes your personal GitHub authorization from PostHog. You
can reconnect at any time. The GitHub App itself stays installed
Expand All @@ -637,7 +703,12 @@ export function GitIntegrationStep({
<Button
variant="solid"
color="red"
onClick={() => disconnectMutation.mutate({})}
onClick={() => {
if (!disconnectTarget) return;
disconnectMutation.mutate({
installationId: disconnectTarget.installationId,
});
}}
disabled={disconnectMutation.isPending}
>
{disconnectMutation.isPending ? <Spinner size="1" /> : null}
Expand Down
8 changes: 7 additions & 1 deletion apps/code/src/renderer/hooks/useIntegrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ function useAllUserGithubRepositories(
})),
combine: (results) => {
const map: Record<string, UserRepositoryIntegrationRef> = {};
const reposByInstallationId: Record<string, string[]> = {};
const failedInstallationIds: string[] = [];
let pending = false;
results.forEach((result, index) => {
Expand All @@ -158,7 +159,9 @@ function useAllUserGithubRepositories(
if (installationId) failedInstallationIds.push(installationId);
}
if (!result.data) return;
for (const repo of result.data.repos ?? []) {
const installationRepos = result.data.repos ?? [];
reposByInstallationId[result.data.installationId] = installationRepos;
Comment on lines +162 to +163

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 reposByInstallationId not populated for error/loading states

When a result is in error or still pending, result.data is falsy and the function returns early, leaving reposByInstallationId[installationId] undefined. In the component this correctly falls back to [] for stale installs (which show "Reconnect needed" instead), so the current behaviour is fine. However, it also means you can't distinguish "no repos returned by the API" (empty array) from "query hasn't completed yet" (undefined) at the component level — a distinction that would fix the per-installation loading concern above.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/hooks/useIntegrations.ts
Line: 162-163

Comment:
**`reposByInstallationId` not populated for error/loading states**

When a result is in error or still pending, `result.data` is falsy and the function returns early, leaving `reposByInstallationId[installationId]` undefined. In the component this correctly falls back to `[]` for stale installs (which show "Reconnect needed" instead), so the current behaviour is fine. However, it also means you can't distinguish "no repos returned by the API" (empty array) from "query hasn't completed yet" (undefined) at the component level — a distinction that would fix the per-installation loading concern above.

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

for (const repo of installationRepos) {
if (!(repo in map)) {
map[repo] = {
userIntegrationId: result.data.userIntegrationId,
Expand All @@ -169,6 +172,7 @@ function useAllUserGithubRepositories(
});
return {
repositoryMap: map,
reposByInstallationId,
isPending: pending,
failedInstallationIds,
};
Expand Down Expand Up @@ -501,6 +505,7 @@ export function useUserRepositoryIntegration() {

const {
repositoryMap,
reposByInstallationId,
isPending: reposPending,
failedInstallationIds,
} = useAllUserGithubRepositories(githubIntegrations);
Expand Down Expand Up @@ -569,6 +574,7 @@ export function useUserRepositoryIntegration() {
refreshRepositories,
hasGithubIntegration: githubIntegrations.length > 0,
failedInstallationIds,
reposByInstallationId,
};
}

Expand Down
Loading