From 904c165ed7474591b2dd892f3bc3277477c348f9 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Tue, 19 May 2026 07:59:25 +0200 Subject: [PATCH] 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 ( -