From 4ed00cc9d3a3d0437b11e7320cdbf6296e7c9f62 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Wed, 29 Apr 2026 21:56:11 +0100 Subject: [PATCH 1/3] feat: allow user to use existing integration to connect in PC --- apps/code/src/renderer/api/posthogClient.ts | 27 ++ .../components/GitIntegrationStep.tsx | 271 +++++++++++++----- 2 files changed, 228 insertions(+), 70 deletions(-) diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index a7f6b141b..615dd3f6f 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1330,6 +1330,33 @@ export class PostHogAPIClient { return data.results ?? data ?? []; } + async linkExistingGithubIntegration( + projectId: number, + sourceIntegrationId: number, + ) { + const path = `/api/environments/${projectId}/integrations/github/link_existing/`; + const url = new URL(`${this.api.baseUrl}${path}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path, + overrides: { + body: JSON.stringify({ source_integration_id: sourceIntegrationId }), + }, + }); + + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + detail?: string; + }; + throw new Error( + errorData.detail ?? + `Failed to link GitHub integration: ${response.statusText}`, + ); + } + return response.json(); + } + async getGithubBranches( integrationId: string | number, repo: string, diff --git a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx index 97f1e2748..bdb3b30c9 100644 --- a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx @@ -16,11 +16,18 @@ import { GearSix, GitBranch, } from "@phosphor-icons/react"; -import { Box, Button, Callout, Flex, Skeleton, Text } from "@radix-ui/themes"; +import { + Box, + Button, + DropdownMenu, + Flex, + Skeleton, + Text, +} from "@radix-ui/themes"; import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; import { trpcClient } from "@renderer/trpc/client"; import { IS_DEV } from "@shared/constants/environment"; -import { useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion } from "framer-motion"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; @@ -63,22 +70,11 @@ export function GitIntegrationStep({ const setConnectingGithub = useOnboardingStore( (state) => state.setConnectingGithub, ); - const manuallySelectedProjectId = useOnboardingStore( - (state) => state.selectedProjectId, - ); - const setSelectedProjectId = useOnboardingStore( - (state) => state.selectProjectId, - ); const pollTimerRef = useRef | null>(null); const pollTimeoutRef = useRef | null>(null); const [timedOut, setTimedOut] = useState(false); - const selectedProjectId = useMemo(() => { - if (manuallySelectedProjectId !== null) { - return manuallySelectedProjectId; - } - return currentProjectId ?? projects[0]?.id ?? null; - }, [manuallySelectedProjectId, currentProjectId, projects]); + const selectedProjectId = currentProjectId ?? projects[0]?.id ?? null; const selectedProject = useMemo( () => projects.find((p) => p.id === selectedProjectId), @@ -90,12 +86,75 @@ export function GitIntegrationStep({ const { githubIntegrations } = useIntegrationSelectors(); const githubIntegration = githubIntegrations[0] ?? null; - const alternativeConnectedProject = useMemo(() => { - if (hasGitIntegration) return null; - if (!projectsWithGithub.length) return null; - return projectsWithGithub.find((p) => p.id !== selectedProjectId) ?? null; + const availableInstallations = useMemo(() => { + if (hasGitIntegration) return []; + const seen = new Map< + string, + { + installationKey: string; + accountName: string | null; + sourceIntegrationId: number; + sourceProjectId: number; + sourceProjectName: string; + } + >(); + for (const project of projectsWithGithub) { + if (project.id === selectedProjectId) continue; + for (const integration of project.integrations) { + if (integration.kind !== "github") continue; + const installationId = integration.config?.installation_id; + const key = String(installationId ?? `int-${integration.id}`); + if (seen.has(key)) continue; + seen.set(key, { + installationKey: key, + accountName: integration.config?.account?.name ?? null, + sourceIntegrationId: integration.id, + sourceProjectId: project.id, + sourceProjectName: project.name, + }); + } + } + return Array.from(seen.values()); }, [hasGitIntegration, projectsWithGithub, selectedProjectId]); + const [selectedInstallationKey, setSelectedInstallationKey] = useState< + string | null + >(null); + + const selectedInstallation = useMemo(() => { + if (!availableInstallations.length) return null; + return ( + availableInstallations.find( + (i) => i.installationKey === selectedInstallationKey, + ) ?? availableInstallations[0] + ); + }, [availableInstallations, selectedInstallationKey]); + + const linkExistingMutation = useMutation({ + mutationFn: async () => { + if (!client || !selectedProjectId || !selectedInstallation) { + throw new Error("Missing data to link existing integration"); + } + return client.linkExistingGithubIntegration( + selectedProjectId, + selectedInstallation.sourceIntegrationId, + ); + }, + onSuccess: () => { + stopPolling(); + setTimedOut(false); + setConnectingGithub(false); + void queryClient.invalidateQueries({ queryKey: ["integrations"] }); + }, + onError: (error) => { + const message = + error instanceof Error + ? error.message + : "Failed to link existing GitHub installation"; + toast.error(message); + }, + }); + const repoSummary = useMemo(() => { if (repositories.length === 0) return null; const names = repositories.map((r) => r.split("/").pop() ?? r); @@ -122,12 +181,12 @@ export function GitIntegrationStep({ }, []); useEffect(() => { - if (hasGitIntegration && isConnecting) { + if (hasGitIntegration) { stopPolling(); setConnectingGithub(false); setTimedOut(false); } - }, [hasGitIntegration, isConnecting, setConnectingGithub, stopPolling]); + }, [hasGitIntegration, setConnectingGithub, stopPolling]); useEffect(() => stopPolling, [stopPolling]); @@ -160,6 +219,7 @@ export function GitIntegrationStep({ onTimedOut: () => { stopPolling(); setConnectingGithub(false); + void queryClient.invalidateQueries({ queryKey: ["integrations"] }); setTimedOut(true); }, }); @@ -197,9 +257,6 @@ export function GitIntegrationStep({ }; const handleContinue = () => { - if (selectedProjectId && selectedProjectId !== currentProjectId) { - selectProjectMutation.mutate(selectedProjectId); - } onNext(); }; @@ -210,7 +267,10 @@ export function GitIntegrationStep({ align="center" className="h-full w-full pt-[24px] pb-[40px]" > - + - {alternativeConnectedProject && selectedProject && ( - - - GitHub is already connected on{" "} - - {alternativeConnectedProject.name} - {" "} - ({alternativeConnectedProject.organization.name}). Switch to - that project, or click{" "} - Connect GitHub below to - install a new integration on{" "} - {selectedProject.name}. - - - - - - )} - {/* GitHub integration */} ) : !isLoading ? ( - - - {timedOut - ? "We didn't hear back from GitHub. If the browser tab was closed, click Connect again." - : isConnecting - ? "Waiting for GitHub... You'll return here automatically once the install completes." - : "Optional. Unlocks cloud agents and pull request workflows."} - - - + selectedInstallation && selectedProject ? ( + + + {availableInstallations.length > 1 ? ( + <> + + + + + + {availableInstallations.map((opt) => ( + + setSelectedInstallationKey( + opt.installationKey, + ) + } + > + + {opt.accountName ?? "GitHub"} + + + {opt.sourceProjectName} + + + ))} + + {" "} + GitHub orgs are already connected to your + organization. + + ) : selectedInstallation.accountName ? ( + <> + + {selectedInstallation.accountName} + {" "} + already installed on{" "} + + {selectedInstallation.sourceProjectName} + + . + + ) : ( + <> + Already installed on{" "} + + {selectedInstallation.sourceProjectName} + + . + + )} + + + + + + + + ) : ( + + + {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."} + + + + ) ) : null} From 3e204c5aabf9eaf8da59bd1b5c42a474dfe3a80e Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Wed, 29 Apr 2026 22:51:51 +0100 Subject: [PATCH 2/3] refactor: use source_team_id for github link_existing Aligns with the matching backend change in PostHog #57029, which switched from accepting an integration row id to accepting a sibling team id. The team id is already collected in availableInstallations, so this just renames the parameter and drops the now-unused sourceIntegrationId field from the dedupe map. Generated-By: PostHog Code Task-Id: f5372c83-7ea4-41dd-8f04-71191dff5782 --- apps/code/src/renderer/api/posthogClient.ts | 7 ++----- .../features/onboarding/components/GitIntegrationStep.tsx | 4 +--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 615dd3f6f..f8f0390f0 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1330,10 +1330,7 @@ export class PostHogAPIClient { return data.results ?? data ?? []; } - async linkExistingGithubIntegration( - projectId: number, - sourceIntegrationId: number, - ) { + async linkExistingGithubIntegration(projectId: number, sourceTeamId: number) { const path = `/api/environments/${projectId}/integrations/github/link_existing/`; const url = new URL(`${this.api.baseUrl}${path}`); const response = await this.api.fetcher.fetch({ @@ -1341,7 +1338,7 @@ export class PostHogAPIClient { url, path, overrides: { - body: JSON.stringify({ source_integration_id: sourceIntegrationId }), + body: JSON.stringify({ source_team_id: sourceTeamId }), }, }); diff --git a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx index bdb3b30c9..b45aae264 100644 --- a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx @@ -93,7 +93,6 @@ export function GitIntegrationStep({ { installationKey: string; accountName: string | null; - sourceIntegrationId: number; sourceProjectId: number; sourceProjectName: string; } @@ -108,7 +107,6 @@ export function GitIntegrationStep({ seen.set(key, { installationKey: key, accountName: integration.config?.account?.name ?? null, - sourceIntegrationId: integration.id, sourceProjectId: project.id, sourceProjectName: project.name, }); @@ -137,7 +135,7 @@ export function GitIntegrationStep({ } return client.linkExistingGithubIntegration( selectedProjectId, - selectedInstallation.sourceIntegrationId, + selectedInstallation.sourceProjectId, ); }, onSuccess: () => { From 48efd06fb619617e4adec86ac442d89e273f8d5a Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 30 Apr 2026 10:10:04 +0100 Subject: [PATCH 3/3] fix: use correct next path for github integration deep link --- .../services/github-integration/service.ts | 2 +- .../integrations/stores/integrationStore.ts | 1 + .../components/GitIntegrationStep.tsx | 42 +++++++++---------- .../onboarding/stores/onboardingStore.ts | 6 --- 4 files changed, 23 insertions(+), 28 deletions(-) diff --git a/apps/code/src/main/services/github-integration/service.ts b/apps/code/src/main/services/github-integration/service.ts index 2361b94ab..87524cd27 100644 --- a/apps/code/src/main/services/github-integration/service.ts +++ b/apps/code/src/main/services/github-integration/service.ts @@ -61,7 +61,7 @@ export class GitHubIntegrationService extends TypedEventEmitter { try { const cloudUrl = getCloudUrlFromRegion(region); - const nextPath = `/account/social-connected?provider=github&project_id=${projectId}&connect_from=posthog_code`; + const nextPath = `/account-connected/github-integration?provider=github&project_id=${projectId}&connect_from=posthog_code`; const authorizeUrl = `${cloudUrl}/api/environments/${projectId}/integrations/authorize/?kind=github&next=${encodeURIComponent(nextPath)}`; this.clearFlowTimeout(); diff --git a/apps/code/src/renderer/features/integrations/stores/integrationStore.ts b/apps/code/src/renderer/features/integrations/stores/integrationStore.ts index 112f42a9e..88b4a2c24 100644 --- a/apps/code/src/renderer/features/integrations/stores/integrationStore.ts +++ b/apps/code/src/renderer/features/integrations/stores/integrationStore.ts @@ -7,6 +7,7 @@ export interface IntegrationAccount { export interface IntegrationConfig { account?: IntegrationAccount; + installation_id?: number; [key: string]: unknown; } diff --git a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx index b45aae264..50acb9fbe 100644 --- a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx @@ -2,7 +2,10 @@ import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient" import { useSelectProjectMutation } from "@features/auth/hooks/authMutations"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; -import { useIntegrationSelectors } from "@features/integrations/stores/integrationStore"; +import { + type Integration, + useIntegrationSelectors, +} from "@features/integrations/stores/integrationStore"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { useRepositoryIntegration } from "@hooks/useIntegrations"; import { @@ -40,6 +43,16 @@ import { StepActions } from "./StepActions"; const POLL_INTERVAL_MS = 3_000; const POLL_TIMEOUT_MS = 300_000; +function getGithubInstallationSettingsUrl(integration: Integration | null) { + const id = integration?.config?.installation_id; + if (!id) return "https://github.com/settings/installations"; + const account = integration.config?.account; + if (account?.type === "Organization" && account.name) { + return `https://github.com/organizations/${account.name}/settings/installations/${id}`; + } + return `https://github.com/settings/installations/${id}`; +} + interface GitIntegrationStepProps { onNext: () => void; onBack: () => void; @@ -142,7 +155,7 @@ export function GitIntegrationStep({ stopPolling(); setTimedOut(false); setConnectingGithub(false); - void queryClient.invalidateQueries({ queryKey: ["integrations"] }); + invalidateProject(selectedProjectId); }, onError: (error) => { const message = @@ -217,7 +230,7 @@ export function GitIntegrationStep({ onTimedOut: () => { stopPolling(); setConnectingGithub(false); - void queryClient.invalidateQueries({ queryKey: ["integrations"] }); + invalidateProject(selectedProjectId); setTimedOut(true); }, }); @@ -452,24 +465,11 @@ export function GitIntegrationStep({ variant="soft" color="gray" onClick={() => { - const config = githubIntegration?.config as - | { - installation_id?: number; - account?: { - name?: string; - type?: string; - }; - } - | undefined; - const id = config?.installation_id; - const account = config?.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 }); + trpcClient.os.openExternal.mutate({ + url: getGithubInstallationSettingsUrl( + githubIntegration, + ), + }); }} > diff --git a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts index c2563bc63..bddc304a7 100644 --- a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts +++ b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts @@ -9,7 +9,6 @@ interface OnboardingStoreState { currentStep: OnboardingStep; hasCompletedOnboarding: boolean; isConnectingGithub: boolean; - selectedProjectId: number | null; selectedDirectory: string; } @@ -19,7 +18,6 @@ interface OnboardingStoreActions { resetOnboarding: () => void; resetSelections: () => void; setConnectingGithub: (isConnecting: boolean) => void; - selectProjectId: (projectId: number | null) => void; setSelectedDirectory: (path: string) => void; } @@ -29,7 +27,6 @@ const initialState: OnboardingStoreState = { currentStep: "welcome", hasCompletedOnboarding: false, isConnectingGithub: false, - selectedProjectId: null, selectedDirectory: "", }; @@ -48,10 +45,8 @@ export const useOnboardingStore = create()( set({ currentStep: "welcome", isConnectingGithub: false, - selectedProjectId: null, }), setConnectingGithub: (isConnectingGithub) => set({ isConnectingGithub }), - selectProjectId: (selectedProjectId) => set({ selectedProjectId }), setSelectedDirectory: (selectedDirectory) => set({ selectedDirectory }), }), { @@ -59,7 +54,6 @@ export const useOnboardingStore = create()( partialize: (state) => ({ currentStep: state.currentStep, hasCompletedOnboarding: state.hasCompletedOnboarding, - selectedProjectId: state.selectedProjectId, selectedDirectory: state.selectedDirectory, }), },