From 904c165ed7474591b2dd892f3bc3277477c348f9 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Tue, 19 May 2026 07:59:25 +0200 Subject: [PATCH 1/2] feat(integrations): wire github finish setup and installation settings urls Add githubInstallationSettingsUrl helper and connect PostHog client finish_setup for GitHub App installation flow from notification settings. --- apps/code/src/renderer/api/posthogClient.ts | 27 ++++++++++ .../integrations/stores/integrationStore.ts | 1 + .../githubInstallationSettingsUrl.test.ts | 51 +++++++++++++++++++ .../utils/githubInstallationSettingsUrl.ts | 44 ++++++++++++++++ .../sections/GitHubIntegrationSection.tsx | 34 ++++++++++++- 5 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.test.ts create mode 100644 apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.ts diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 011494253..f9b49f9d8 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -646,6 +646,33 @@ export class PostHogAPIClient { }; } + /** Seed team GitHub setup callback state before opening github.com installation settings. */ + async prepareGithubTeamIntegrationCallback( + teamId: number, + next: string, + ): Promise { + const urlPath = `/api/environments/${teamId}/integrations/github/prepare_callback/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify({ next }), + }, + }); + if (!response.ok && response.status !== 204) { + const err = (await response.json().catch(() => ({}))) as { + detail?: unknown; + }; + const detail = + typeof err.detail === "string" + ? err.detail + : "Failed to prepare GitHub callback"; + throw new Error(detail); + } + } + async getGithubUserIntegrations(): Promise { const urlPath = `/api/users/@me/integrations/`; const url = new URL(`${this.api.baseUrl}${urlPath}`); diff --git a/apps/code/src/renderer/features/integrations/stores/integrationStore.ts b/apps/code/src/renderer/features/integrations/stores/integrationStore.ts index 022f1eea8..c79b3915f 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?: string | number | null; [key: string]: unknown; } diff --git a/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.test.ts b/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.test.ts new file mode 100644 index 000000000..f4928e961 --- /dev/null +++ b/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + githubInstallationSettingsUrl, + resolveGithubInstallationId, +} from "./githubInstallationSettingsUrl"; + +describe("githubInstallationSettingsUrl", () => { + it("uses org settings for organization accounts", () => { + expect( + githubInstallationSettingsUrl("99", { + type: "Organization", + name: "posthog", + }), + ).toBe( + "https://github.com/organizations/posthog/settings/installations/99", + ); + }); + + it("uses user settings for personal accounts", () => { + expect( + githubInstallationSettingsUrl("42", { type: "User", name: "octocat" }), + ).toBe("https://github.com/settings/installations/42"); + }); +}); + +describe("resolveGithubInstallationId", () => { + it("prefers top-level installation_id then id then config", () => { + expect( + resolveGithubInstallationId({ + id: 99, + kind: "github", + installation_id: "a", + config: { installation_id: "c" }, + }), + ).toBe("a"); + expect( + resolveGithubInstallationId({ + id: 1, + kind: "github", + integration_id: 12345, + }), + ).toBe("12345"); + expect( + resolveGithubInstallationId({ + id: 1, + kind: "github", + config: { installation_id: "c" }, + }), + ).toBe("c"); + }); +}); diff --git a/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.ts b/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.ts new file mode 100644 index 000000000..512d6d02b --- /dev/null +++ b/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.ts @@ -0,0 +1,44 @@ +import type { Integration } from "../stores/integrationStore"; + +interface GithubInstallationAccount { + type?: string | null; + name?: string | null; +} + +export function githubInstallationSettingsUrl( + installationId: string, + account?: GithubInstallationAccount | null, +): string { + const accountType = account?.type; + const accountName = account?.name; + if ( + typeof accountType === "string" && + accountType.toLowerCase() === "organization" && + typeof accountName === "string" && + accountName + ) { + return `https://github.com/organizations/${accountName}/settings/installations/${installationId}`; + } + return `https://github.com/settings/installations/${installationId}`; +} + +/** Resolves a GitHub App installation id from team or user integration payloads. */ +export function resolveGithubInstallationId( + integration: Integration, +): string | null { + const legacy = integration as { + installation_id?: string | null; + integration_id?: string | number | null; + }; + const candidates = [ + legacy.installation_id, + legacy.integration_id, + integration.config?.installation_id, + ]; + for (const value of candidates) { + if (value === null || value === undefined) continue; + const id = String(value).trim(); + if (id) return id; + } + return null; +} diff --git a/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx b/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx index a094d175d..7843c362a 100644 --- a/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx @@ -1,8 +1,14 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { describeGithubConnectError, useGithubConnect, } from "@features/integrations/hooks/useGithubUserConnect"; +import { useIntegrationSelectors } from "@features/integrations/stores/integrationStore"; +import { + githubInstallationSettingsUrl, + resolveGithubInstallationId, +} from "@features/integrations/utils/githubInstallationSettingsUrl"; import { useRepositoryIntegration } from "@hooks/useIntegrations"; import { ArrowSquareOutIcon, @@ -11,6 +17,7 @@ import { InfoIcon, } from "@phosphor-icons/react"; import { Box, Button, Flex, Spinner, Text, Tooltip } from "@radix-ui/themes"; +import { openUrlInBrowser } from "@utils/browser"; export function GitHubIntegrationSection({ hasGithubIntegration, @@ -18,6 +25,8 @@ export function GitHubIntegrationSection({ hasGithubIntegration: boolean; }) { const { repositories, isLoadingRepos } = useRepositoryIntegration(); + const { githubIntegrations } = useIntegrationSelectors(); + const client = useOptionalAuthenticatedClient(); const projectId = useAuthStateValue((state) => state.projectId); const { error: connectError, @@ -30,6 +39,25 @@ export function GitHubIntegrationSection({ projectHasTeamIntegration: hasGithubIntegration, }); + const handleUpdateInGitHub = async () => { + const integration = githubIntegrations[0]; + if (!integration || projectId === null || !client) return; + const installationId = resolveGithubInstallationId(integration); + if (!installationId) return; + const nextPath = `/account-connected/github-integration?provider=github&project_id=${projectId}&connect_from=posthog_code`; + try { + await client.prepareGithubTeamIntegrationCallback(projectId, nextPath); + } catch { + return; + } + void openUrlInBrowser( + githubInstallationSettingsUrl( + installationId, + integration.config?.account, + ), + ); + }; + return ( - From 97138466db0fbad67924e64f39985e2387b17a39 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Fri, 22 May 2026 18:56:16 +0200 Subject: [PATCH 2/2] Address AI comments --- apps/code/src/renderer/api/posthogClient.ts | 2 +- .../githubInstallationSettingsUrl.test.ts | 44 ++++++++++--------- .../sections/GitHubIntegrationSection.tsx | 11 ++++- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 10f4e7510..9b902c83d 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -663,7 +663,7 @@ export class PostHogAPIClient { body: JSON.stringify({ next }), }, }); - if (!response.ok && response.status !== 204) { + if (!response.ok) { const err = (await response.json().catch(() => ({}))) as { detail?: unknown; }; diff --git a/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.test.ts b/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.test.ts index f4928e961..c78caa2e3 100644 --- a/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.test.ts +++ b/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { Integration } from "../stores/integrationStore"; import { githubInstallationSettingsUrl, resolveGithubInstallationId, @@ -24,28 +25,31 @@ describe("githubInstallationSettingsUrl", () => { }); describe("resolveGithubInstallationId", () => { - it("prefers top-level installation_id then id then config", () => { - expect( - resolveGithubInstallationId({ + it.each([ + [ + "prefers top-level installation_id over integration_id and config", + { id: 99, kind: "github", installation_id: "a", config: { installation_id: "c" }, - }), - ).toBe("a"); - expect( - resolveGithubInstallationId({ - id: 1, - kind: "github", - integration_id: 12345, - }), - ).toBe("12345"); - expect( - resolveGithubInstallationId({ - id: 1, - kind: "github", - config: { installation_id: "c" }, - }), - ).toBe("c"); - }); + }, + "a", + ], + [ + "falls back to integration_id when installation_id is absent", + { id: 1, kind: "github", integration_id: 12345 }, + "12345", + ], + [ + "falls back to config.installation_id as last resort", + { id: 1, kind: "github", config: { installation_id: "c" } }, + "c", + ], + ] satisfies [string, Integration, string][])( + "%s", + (_label, input, expected) => { + expect(resolveGithubInstallationId(input)).toBe(expected); + }, + ); }); diff --git a/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx b/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx index ae555c0af..d3d92be92 100644 --- a/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx @@ -19,6 +19,7 @@ import { import { Box, Button, Flex, Spinner, Text, Tooltip } from "@radix-ui/themes"; import { openUrlInBrowser } from "@utils/browser"; import { useMemo } from "react"; +import { toast } from "sonner"; /** * Past this count, the tooltip would become an unreadable wall of `owner/name` @@ -70,11 +71,17 @@ export function GitHubIntegrationSection({ const integration = githubIntegrations[0]; if (!integration || projectId === null || !client) return; const installationId = resolveGithubInstallationId(integration); - if (!installationId) return; + if (!installationId) { + toast.error("Couldn't find GitHub installation details"); + return; + } const nextPath = `/account-connected/github-integration?provider=github&project_id=${projectId}&connect_from=posthog_code`; try { await client.prepareGithubTeamIntegrationCallback(projectId, nextPath); - } catch { + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to open GitHub settings", + ); return; } void openUrlInBrowser(