diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 2006cbf17..9b902c83d 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -648,6 +648,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) { + 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..c78caa2e3 --- /dev/null +++ b/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import type { Integration } from "../stores/integrationStore"; +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.each([ + [ + "prefers top-level installation_id over integration_id and config", + { + id: 99, + kind: "github", + installation_id: "a", + config: { installation_id: "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/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 4786b8037..d3d92be92 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,7 +17,9 @@ import { InfoIcon, } from "@phosphor-icons/react"; 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` @@ -38,6 +46,8 @@ export function GitHubIntegrationSection({ hasGithubIntegration: boolean; }) { const { repositories, isLoadingRepos } = useRepositoryIntegration(); + const { githubIntegrations } = useIntegrationSelectors(); + const client = useOptionalAuthenticatedClient(); const ownerSummary = useMemo( () => repositories.length > REPO_LIST_TOOLTIP_THRESHOLD @@ -57,6 +67,31 @@ export function GitHubIntegrationSection({ projectHasTeamIntegration: hasGithubIntegration, }); + const handleUpdateInGitHub = async () => { + const integration = githubIntegrations[0]; + if (!integration || projectId === null || !client) return; + const installationId = resolveGithubInstallationId(integration); + 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 (err) { + toast.error( + err instanceof Error ? err.message : "Failed to open GitHub settings", + ); + return; + } + void openUrlInBrowser( + githubInstallationSettingsUrl( + installationId, + integration.config?.account, + ), + ); + }; + return ( -