Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit e4ac7e5

Browse files
authored
feat(code): list all GitHub installations and allow adding another org (#2001)
## Problem Users with GitHub integrations connected to multiple organizations had no way to manage them individually — the UI only showed a single integration, and disconnect/reconnect actions were scoped to whichever integration happened to be first in the list. ## Changes - The GitHub integration panel now renders a card for each connected installation, showing the account name, type (org vs. personal), and repo count - Disconnect is now per-installation: each card has its own "Disconnect" button, and the confirmation dialog names the specific account being removed - Reconnect and Settings actions are also scoped per-installation, with loading state tied to the specific installation being acted on - An "Add another GitHub org" button replaces the old single disconnect button at the bottom of the list, allowing users to connect additional organizations without removing existing ones - The status badge updates to show `Connected (N)` when more than one integration is active, and "Reconnect needed" is shown when any integration is stale - `useUserRepositoryIntegration` now exposes `reposByInstallationId` so each card can display the correct repo count for its installation
1 parent 15c30a9 commit e4ac7e5

2 files changed

Lines changed: 164 additions & 87 deletions

File tree

‎apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx‎

Lines changed: 157 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
FolderOpen,
2323
GearSix,
2424
GitBranch,
25+
Plus,
2526
} from "@phosphor-icons/react";
2627
import {
2728
AlertDialog,
@@ -110,12 +111,11 @@ export function GitIntegrationStep({
110111
isLoading: githubUserIntegrationsLoading,
111112
} = useUserGithubIntegrations();
112113
const hasGitIntegration = githubUserIntegrations.length > 0;
113-
const { repositories, isLoadingRepos, failedInstallationIds } =
114+
const { repositories, failedInstallationIds, reposByInstallationId } =
114115
useUserRepositoryIntegration();
115-
const githubIntegration = githubUserIntegrations[0] ?? null;
116-
const integrationIsStale =
117-
!!githubIntegration &&
118-
failedInstallationIds.includes(githubIntegration.installation_id);
116+
const anyIntegrationStale = githubUserIntegrations.some((i) =>
117+
failedInstallationIds.includes(i.installation_id),
118+
);
119119

120120
const alternativeConnectedProjects = useMemo(() => {
121121
if (hasGitIntegration) return [];
@@ -136,13 +136,6 @@ export function GitIntegrationStep({
136136
);
137137
}, [alternativeConnectedProjects, selectedAlternativeId]);
138138

139-
const repoSummary = useMemo(() => {
140-
if (repositories.length === 0) return null;
141-
const names = repositories.map((r) => r.split("/").pop() ?? r);
142-
if (names.length <= 2) return names.join(" and ");
143-
return `${names[0]}, ${names[1]} and ${names.length - 2} more`;
144-
}, [repositories]);
145-
146139
const repoMatchesGitHub = useMemo(() => {
147140
if (!detectedRepo || repositories.length === 0) return false;
148141
return repositories.some(
@@ -151,18 +144,23 @@ export function GitIntegrationStep({
151144
}, [detectedRepo, repositories]);
152145

153146
const apiClient = useOptionalAuthenticatedClient();
154-
const [disconnectConfirmOpen, setDisconnectConfirmOpen] = useState(false);
147+
const [disconnectTarget, setDisconnectTarget] = useState<{
148+
installationId: string;
149+
accountName: string;
150+
} | null>(null);
151+
const [reconnectingInstallationId, setReconnectingInstallationId] = useState<
152+
string | null
153+
>(null);
155154
const disconnectMutation = useMutation({
156-
mutationFn: async (opts: { silent?: boolean } = {}) => {
157-
const installationId = githubIntegration?.installation_id;
158-
if (!apiClient || !installationId) {
159-
throw new Error("No GitHub integration to disconnect");
155+
mutationFn: async (opts: { installationId: string; silent?: boolean }) => {
156+
if (!apiClient) {
157+
throw new Error("Not authenticated");
160158
}
161-
await apiClient.disconnectGithubUserIntegration(installationId);
159+
await apiClient.disconnectGithubUserIntegration(opts.installationId);
162160
return { silent: opts.silent ?? false };
163161
},
164162
onSuccess: ({ silent }) => {
165-
setDisconnectConfirmOpen(false);
163+
setDisconnectTarget(null);
166164
invalidateGithubQueries(queryClient, selectedProjectId);
167165
if (!silent) toast.success("GitHub disconnected.");
168166
},
@@ -344,7 +342,7 @@ export function GitIntegrationStep({
344342
{isLoading || githubUserIntegrationsLoading ? (
345343
<Skeleton className="h-[16px] w-[80px]" />
346344
) : hasGitIntegration ? (
347-
integrationIsStale ? (
345+
anyIntegrationStale ? (
348346
<Text className="text-(--amber-11) text-[13px]">
349347
Reconnect needed
350348
</Text>
@@ -356,72 +354,136 @@ export function GitIntegrationStep({
356354
className="text-(--green-9)"
357355
/>
358356
<Text className="text-(--green-11) text-[13px]">
359-
Connected
357+
{githubUserIntegrations.length > 1
358+
? `Connected (${githubUserIntegrations.length})`
359+
: "Connected"}
360360
</Text>
361361
</Flex>
362362
)
363363
) : null}
364364
</Flex>
365365
{hasGitIntegration ? (
366366
<Flex direction="column" gap="3">
367-
<Text
368-
className={
369-
integrationIsStale
370-
? "text-(--amber-11) text-sm"
371-
: "text-(--gray-11) text-sm"
372-
}
373-
>
374-
{integrationIsStale
375-
? "We can't reach GitHub with your saved authorization. The app may have been uninstalled or the token revoked. Reconnect to restore access."
376-
: isLoadingRepos
377-
? "Loading repositories..."
378-
: repoSummary
379-
? `Access to ${repoSummary}`
380-
: "No repositories found. Check your GitHub app settings."}
381-
</Text>
382-
<Flex align="center" gap="3" wrap="wrap">
383-
{integrationIsStale && (
384-
<Button
385-
size="1"
386-
variant="solid"
387-
loading={
388-
disconnectMutation.isPending || isConnecting
389-
}
390-
onClick={async () => {
391-
if (!githubIntegration?.installation_id) return;
392-
try {
393-
await disconnectMutation.mutateAsync({
394-
silent: true,
395-
});
396-
} catch {
397-
return;
398-
}
399-
void handleConnectGitHub();
400-
}}
367+
{githubUserIntegrations.map((integration) => {
368+
const installationId = integration.installation_id;
369+
const accountName =
370+
integration.account?.name ?? "GitHub";
371+
const installRepos =
372+
reposByInstallationId[installationId];
373+
const isLoadingInstallRepos =
374+
installRepos === undefined;
375+
const isStale =
376+
failedInstallationIds.includes(installationId);
377+
const isReconnecting =
378+
reconnectingInstallationId === installationId;
379+
return (
380+
<Flex
381+
key={integration.id}
382+
direction="column"
383+
gap="2"
384+
p="3"
385+
className="rounded-[8px] border border-(--gray-a3)"
401386
>
402-
Reconnect
403-
<ArrowSquareOut size={12} />
404-
</Button>
405-
)}
406-
<Button
407-
size="1"
408-
variant="soft"
409-
color="gray"
410-
onClick={() => {
411-
const id = githubIntegration?.installation_id;
412-
const account = githubIntegration?.account;
413-
const url = id
414-
? account?.type === "Organization" &&
415-
account.name
416-
? `https://github.com/organizations/${account.name}/settings/installations/${id}`
417-
: `https://github.com/settings/installations/${id}`
418-
: "https://github.com/settings/installations";
419-
trpcClient.os.openExternal.mutate({ url });
420-
}}
421-
>
422-
<GearSix size={12} />
423-
Settings
424-
</Button>
387+
<Flex
388+
align="center"
389+
justify="between"
390+
gap="2"
391+
wrap="wrap"
392+
>
393+
<Flex align="center" gap="2">
394+
<Text className="font-bold text-(--gray-12) text-sm">
395+
{accountName}
396+
</Text>
397+
<Text className="text-(--gray-10) text-[12px]">
398+
{integration.account?.type ===
399+
"Organization"
400+
? "org"
401+
: "personal"}
402+
</Text>
403+
</Flex>
404+
{isStale ? (
405+
<Text className="text-(--amber-11) text-[12px]">
406+
Reconnect needed
407+
</Text>
408+
) : (
409+
<Text className="text-(--gray-10) text-[12px]">
410+
{isLoadingInstallRepos
411+
? "Loading…"
412+
: installRepos.length === 1
413+
? "1 repo"
414+
: `${installRepos.length} repos`}
415+
</Text>
416+
)}
417+
</Flex>
418+
<Flex align="center" gap="3" wrap="wrap">
419+
{isStale && (
420+
<Button
421+
size="1"
422+
variant="solid"
423+
loading={isReconnecting}
424+
disabled={
425+
reconnectingInstallationId !== null &&
426+
!isReconnecting
427+
}
428+
onClick={async () => {
429+
setReconnectingInstallationId(
430+
installationId,
431+
);
432+
try {
433+
await disconnectMutation.mutateAsync({
434+
installationId,
435+
silent: true,
436+
});
437+
} catch {
438+
setReconnectingInstallationId(null);
439+
return;
440+
}
441+
try {
442+
await handleConnectGitHub();
443+
} finally {
444+
setReconnectingInstallationId(null);
445+
}
446+
}}
447+
>
448+
Reconnect
449+
<ArrowSquareOut size={12} />
450+
</Button>
451+
)}
452+
<Button
453+
size="1"
454+
variant="soft"
455+
color="gray"
456+
onClick={() => {
457+
const account = integration.account;
458+
const url =
459+
account?.type === "Organization" &&
460+
account.name
461+
? `https://github.com/organizations/${account.name}/settings/installations/${installationId}`
462+
: `https://github.com/settings/installations/${installationId}`;
463+
trpcClient.os.openExternal.mutate({ url });
464+
}}
465+
>
466+
<GearSix size={12} />
467+
Settings
468+
</Button>
469+
<Button
470+
size="1"
471+
variant="soft"
472+
color="red"
473+
onClick={() =>
474+
setDisconnectTarget({
475+
installationId,
476+
accountName,
477+
})
478+
}
479+
>
480+
Disconnect
481+
</Button>
482+
</Flex>
483+
</Flex>
484+
);
485+
})}
486+
<Flex align="center" gap="3" wrap="wrap">
425487
<Button
426488
size="1"
427489
variant="soft"
@@ -441,11 +503,12 @@ export function GitIntegrationStep({
441503
<Button
442504
size="1"
443505
variant="ghost"
444-
color="red"
445-
disabled={!githubIntegration?.installation_id}
446-
onClick={() => setDisconnectConfirmOpen(true)}
506+
color="gray"
507+
onClick={() => void handleConnectGitHub()}
508+
loading={isConnecting}
447509
>
448-
Disconnect
510+
<Plus size={12} />
511+
Add another GitHub org
449512
</Button>
450513
</Flex>
451514
</Flex>
@@ -609,15 +672,18 @@ export function GitIntegrationStep({
609672
</StepActions>
610673

611674
<AlertDialog.Root
612-
open={disconnectConfirmOpen}
675+
open={disconnectTarget !== null}
613676
onOpenChange={(next) => {
614677
if (!next && !disconnectMutation.isPending) {
615-
setDisconnectConfirmOpen(false);
678+
setDisconnectTarget(null);
616679
}
617680
}}
618681
>
619682
<AlertDialog.Content maxWidth="450px">
620-
<AlertDialog.Title>Disconnect GitHub</AlertDialog.Title>
683+
<AlertDialog.Title>
684+
Disconnect{" "}
685+
{disconnectTarget ? disconnectTarget.accountName : "GitHub"}
686+
</AlertDialog.Title>
621687
<AlertDialog.Description className="text-sm">
622688
This removes your personal GitHub authorization from PostHog. You
623689
can reconnect at any time. The GitHub App itself stays installed
@@ -637,7 +703,12 @@ export function GitIntegrationStep({
637703
<Button
638704
variant="solid"
639705
color="red"
640-
onClick={() => disconnectMutation.mutate({})}
706+
onClick={() => {
707+
if (!disconnectTarget) return;
708+
disconnectMutation.mutate({
709+
installationId: disconnectTarget.installationId,
710+
});
711+
}}
641712
disabled={disconnectMutation.isPending}
642713
>
643714
{disconnectMutation.isPending ? <Spinner size="1" /> : null}

‎apps/code/src/renderer/hooks/useIntegrations.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ function useAllUserGithubRepositories(
148148
})),
149149
combine: (results) => {
150150
const map: Record<string, UserRepositoryIntegrationRef> = {};
151+
const reposByInstallationId: Record<string, string[]> = {};
151152
const failedInstallationIds: string[] = [];
152153
let pending = false;
153154
results.forEach((result, index) => {
@@ -158,7 +159,9 @@ function useAllUserGithubRepositories(
158159
if (installationId) failedInstallationIds.push(installationId);
159160
}
160161
if (!result.data) return;
161-
for (const repo of result.data.repos ?? []) {
162+
const installationRepos = result.data.repos ?? [];
163+
reposByInstallationId[result.data.installationId] = installationRepos;
164+
for (const repo of installationRepos) {
162165
if (!(repo in map)) {
163166
map[repo] = {
164167
userIntegrationId: result.data.userIntegrationId,
@@ -169,6 +172,7 @@ function useAllUserGithubRepositories(
169172
});
170173
return {
171174
repositoryMap: map,
175+
reposByInstallationId,
172176
isPending: pending,
173177
failedInstallationIds,
174178
};
@@ -501,6 +505,7 @@ export function useUserRepositoryIntegration() {
501505

502506
const {
503507
repositoryMap,
508+
reposByInstallationId,
504509
isPending: reposPending,
505510
failedInstallationIds,
506511
} = useAllUserGithubRepositories(githubIntegrations);
@@ -569,6 +574,7 @@ export function useUserRepositoryIntegration() {
569574
refreshRepositories,
570575
hasGithubIntegration: githubIntegrations.length > 0,
571576
failedInstallationIds,
577+
reposByInstallationId,
572578
};
573579
}
574580

0 commit comments

Comments
 (0)