From 1448c4115a9c0571509adec0dc7efb65262b692a Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Thu, 30 Jul 2026 19:17:51 +0100 Subject: [PATCH 01/13] fix(integrations): let users pick which GitHub installation to link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GitHub App installs once per org, so a second project reuses an existing installation via "Link existing installation". That button sent no installation_id, hitting the backend auto-resolve path, which raises an ambiguous-installation error when the org has more than one installation — leaving no way to link. Add a github/available_installations endpoint that lists the org's distinct installations, and surface a per-installation picker in the UI that passes the chosen installation_id to the existing github/link_existing endpoint. When the org has a single installation the one-click behavior is unchanged. Generated-By: PostHog Code Task-Id: ae6ad9b6-969c-4e53-9d09-4d2086ea6bf5 --- frontend/src/lib/api.ts | 12 ++++ .../src/lib/integrations/integrationsLogic.ts | 45 +++++++++++++-- .../integrations/components/Integrations.tsx | 48 +++++++++++----- posthog/api/github_callback/team_services.py | 37 ++++++++++++ posthog/api/integration.py | 34 +++++++++++ posthog/api/test/test_integration.py | 57 +++++++++++++++++++ 6 files changed, 214 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5fb6363e57cf..ce503e2ac3b0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -293,6 +293,15 @@ import type { ProductIntentProperties } from './utils/product-intents' export type CheckboxValueType = string | number | boolean +// Mirrors GitHubAvailableInstallationSerializer. Replace with the generated `GitHubAvailableInstallationApi` +// once `hogli build:openapi` has been run against the new github/available_installations endpoint. +export interface GitHubAvailableInstallation { + installation_id: string + account_name: string | null + account_type: string | null + source_team_id: number +} + const PAGINATION_DEFAULT_MAX_PAGES = 10 export interface PaginatedResponse { @@ -6314,6 +6323,9 @@ const api = { ): Promise { return await new ApiRequest().integrations(teamId).withAction('github/link_existing').create({ data }) }, + async githubAvailableInstallations(teamId?: TeamType['id']): Promise { + return await new ApiRequest().integrations(teamId).withAction('github/available_installations').get() + }, async githubOAuthAuthorize( data: { installation_id: string | number diff --git a/frontend/src/lib/integrations/integrationsLogic.ts b/frontend/src/lib/integrations/integrationsLogic.ts index f8532040b010..a2de5f9b39f5 100644 --- a/frontend/src/lib/integrations/integrationsLogic.ts +++ b/frontend/src/lib/integrations/integrationsLogic.ts @@ -4,7 +4,7 @@ import { router, urlToAction } from 'kea-router' import { LemonDialog, lemonToast } from '@posthog/lemon-ui' -import api, { ApiError, getCookie } from 'lib/api' +import api, { ApiError, getCookie, GitHubAvailableInstallation } from 'lib/api' import { globalSetupLogic } from 'lib/components/ProductSetup' import { describeGithubSetupError, GITHUB_INSTALL_PENDING_MESSAGE } from 'lib/integrations/githubSetupErrors' import { describeOAuthCallbackError } from 'lib/integrations/oauthCallbackErrors' @@ -89,6 +89,8 @@ export interface integrationsLogicValues { githubIntegrations: IntegrationType[] githubRepositories: Record githubRepositoriesLoading: boolean + githubAvailableInstallations: GitHubAvailableInstallation[] | null + githubAvailableInstallationsLoading: boolean integrations: IntegrationType[] | null integrationsLoading: boolean linkedGithubInstallation: IntegrationType | null @@ -162,7 +164,7 @@ export interface integrationsLogicActions { | 'vercel' searchParams: any } - linkExistingGithubInstallation: () => any + linkExistingGithubInstallation: (installationId?: string) => string | undefined linkExistingGithubInstallationFailure: ( error: string, errorObject?: any @@ -177,6 +179,21 @@ export interface integrationsLogicActions { linkedGithubInstallation: IntegrationType payload?: any } + loadGithubAvailableInstallations: () => any + loadGithubAvailableInstallationsFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + loadGithubAvailableInstallationsSuccess: ( + githubAvailableInstallations: GitHubAvailableInstallation[], + payload?: any + ) => { + githubAvailableInstallations: GitHubAvailableInstallation[] + payload?: any + } loadGitHubRepositories: (integrationId: number) => { integrationId: number } @@ -814,9 +831,13 @@ export const integrationsLogic = kea([ // Reuse a GitHub App installation already connected to another project in the same // org. A GitHub App installs once per org, so a second project can't reinstall; this // links the existing install without the fragile GitHub setup redirect roundtrip. - linkExistingGithubInstallation: async () => { + // When the org has more than one installation the caller passes the chosen + // installationId, since the backend can't auto-resolve between them. + linkExistingGithubInstallation: async (installationId?: string) => { try { - const integration = await api.integrations.githubLinkExisting({}) + const integration = await api.integrations.githubLinkExisting( + installationId ? { installation_id: installationId } : {} + ) lemonToast.success('Linked the existing GitHub installation to this project.') actions.loadIntegrations() return integration @@ -827,6 +848,14 @@ export const integrationsLogic = kea([ }, }, ], + githubAvailableInstallations: [ + null as GitHubAvailableInstallation[] | null, + { + loadGithubAvailableInstallations: async () => { + return await api.integrations.githubAvailableInstallations() + }, + }, + ], accessRequest: [ null as IntegrationKind | null, { @@ -847,6 +876,14 @@ export const integrationsLogic = kea([ ], })), listeners(({ actions, values }) => ({ + loadIntegrationsSuccess: ({ integrations }) => { + // "Link existing installation" only applies when this project has no GitHub integration + // yet. Fetch the org's installations so the UI can offer a picker when more than one + // exists, rather than failing the auto-resolve link with an ambiguous-installation error. + if (!integrations?.some((integration) => integration.kind === 'github')) { + actions.loadGithubAvailableInstallations() + } + }, loadGitHubRepositories: ({ integrationId }) => { actions.loadGitHubRepositoriesPage(integrationId, 0) }, diff --git a/frontend/src/scenes/integrations/components/Integrations.tsx b/frontend/src/scenes/integrations/components/Integrations.tsx index de8000c86df6..389fb551781f 100644 --- a/frontend/src/scenes/integrations/components/Integrations.tsx +++ b/frontend/src/scenes/integrations/components/Integrations.tsx @@ -30,7 +30,7 @@ export function LinearIntegration({ next }: { next?: string }): JSX.Element { export function GithubIntegration({ next }: { next?: string }): JSX.Element { const { currentTeam } = useValues(teamLogic) - const { linkedGithubInstallationLoading } = useValues(integrationsLogic) + const { linkedGithubInstallationLoading, githubAvailableInstallations } = useValues(integrationsLogic) const { linkExistingGithubInstallation } = useActions(integrationsLogic) const githubIntegrations = useIntegrations('github') @@ -40,28 +40,46 @@ export function GithubIntegration({ next }: { next?: string }): JSX.Element { kind: 'github', }) + const installations = githubAvailableInstallations ?? [] + // A GitHub App installs once per org, so reuse an existing installation rather than reinstall — + // but only when this project has none and the org has one to link. + const canLinkExisting = githubIntegrations.length === 0 && installations.length > 0 + const multipleInstallations = installations.length > 1 + return (
-
+
Connect organization - {githubIntegrations.length === 0 && ( - linkExistingGithubInstallation()} - > - Link existing installation - - )} + {canLinkExisting && + (multipleInstallations ? ( + installations.map((installation) => ( + linkExistingGithubInstallation(installation.installation_id)} + > + Link {installation.account_name ?? `installation ${installation.installation_id}`} + + )) + ) : ( + linkExistingGithubInstallation()} + > + Link existing installation + + ))}
- {githubIntegrations.length === 0 && ( + {canLinkExisting && (

- Already installed the PostHog GitHub App for another project in this organization? A GitHub App - installs once per organization, so use "Link existing installation" to connect it here instead - of reinstalling. + {multipleInstallations + ? 'Your organization has more than one PostHog GitHub App installation. A GitHub App installs once per organization, so pick the one to connect to this project.' + : 'Already installed the PostHog GitHub App for another project in this organization? A GitHub App installs once per organization, so use "Link existing installation" to connect it here instead of reinstalling.'}

)}
diff --git a/posthog/api/github_callback/team_services.py b/posthog/api/github_callback/team_services.py index 8b269d4223bb..8e0fb736a343 100644 --- a/posthog/api/github_callback/team_services.py +++ b/posthog/api/github_callback/team_services.py @@ -491,6 +491,43 @@ def link_existing_team_github_integration( return instance +def list_org_github_installations( + *, + organization: Organization, + exclude_team_id: int | None = None, +) -> list[dict[str, Any]]: + """List the distinct GitHub App installations already linked anywhere in ``organization``. + + A GitHub App installs once per org, so when an org has more than one installation the caller + can't rely on the single-install auto-resolve path in ``link_existing_team_github_integration``. + This enumerates the installations so the UI can offer a picker and pass an explicit + ``installation_id``. The first integration seen for each installation id (deterministic + ``order_by("id")``) provides the representative account metadata and source team. + """ + org_github = Integration.objects.filter(team__organization_id=organization.id, kind="github") + if exclude_team_id is not None: + org_github = org_github.exclude(team_id=exclude_team_id) + org_github = org_github.order_by("id") + + installations: dict[str, dict[str, Any]] = {} + for integration in org_github: + config = integration.config or {} + raw_installation_id = config.get("installation_id") + if not raw_installation_id: + continue + installation_id = str(raw_installation_id) + if installation_id in installations: + continue + account = config.get("account") or {} + installations[installation_id] = { + "installation_id": installation_id, + "account_name": account.get("name") or config.get("connecting_user_github_login"), + "account_type": account.get("type"), + "source_team_id": integration.team_id, + } + return list(installations.values()) + + def finish_team_setup(http_request) -> FinishResult: state_raw = http_request.GET.get("state") user = cast(User, http_request.user) diff --git a/posthog/api/integration.py b/posthog/api/integration.py index fb39fdfc234d..7da84a27ee6d 100644 --- a/posthog/api/integration.py +++ b/posthog/api/integration.py @@ -26,6 +26,7 @@ build_team_oauth_authorize_url, create_team_github_integration_from_oauth_code, link_existing_team_github_integration, + list_org_github_installations, ) from posthog.api.github_callback.types import ( FlowKind, @@ -901,6 +902,23 @@ class GitHubLinkExistingRequestSerializer(serializers.Serializer): ) +class GitHubAvailableInstallationSerializer(serializers.Serializer): + installation_id = serializers.CharField( + help_text="GitHub installation ID to pass to github/link_existing when linking this installation." + ) + account_name = serializers.CharField( + allow_null=True, + help_text="GitHub account (organization or user) the installation belongs to, for display in the picker.", + ) + account_type = serializers.CharField( + allow_null=True, + help_text="GitHub account type, e.g. 'Organization' or 'User'.", + ) + source_team_id = serializers.IntegerField( + help_text="A project in the organization that already has this installation linked.", + ) + + class GitHubOAuthAuthorizeRequestSerializer(serializers.Serializer): installation_id = serializers.CharField( required=False, @@ -955,6 +973,7 @@ class IntegrationViewSet( "github_repos", "github_branches", "github_teams", + "github_available_installations", "jira_projects", "linear_teams", "anthropic_managed_agents", @@ -1551,6 +1570,21 @@ def github_prepare_callback(self, request: Request, *args: Any, **kwargs: Any) - ) return Response(status=204) + @extend_schema(responses={200: GitHubAvailableInstallationSerializer(many=True)}) + @action(methods=["GET"], detail=False, url_path="github/available_installations") + def github_available_installations(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """List the org's existing GitHub installations this project can reuse. + + A GitHub App installs once per organization, so a second project links an existing + installation rather than reinstalling. This backs the picker: when the org has more than + one installation, the client passes the chosen installation_id to github/link_existing. + """ + installations = list_org_github_installations( + organization=self.organization, + exclude_team_id=self.team_id, + ) + return Response(GitHubAvailableInstallationSerializer(installations, many=True).data) + @extend_schema( request=GitHubLinkExistingRequestSerializer, responses={200: IntegrationSerializer}, diff --git a/posthog/api/test/test_integration.py b/posthog/api/test/test_integration.py index 69362c6a9320..ed338eacbf7d 100644 --- a/posthog/api/test/test_integration.py +++ b/posthog/api/test/test_integration.py @@ -24,6 +24,7 @@ GITHUB_LINK_EXISTING_ERROR_PERSONAL_GITHUB_REQUIRED, authorize_link_existing_installation, link_existing_team_github_integration, + list_org_github_installations, ) from posthog.api.github_callback.types import FlowKind, GitHubAuthorizeState from posthog.api.integration import IntegrationSerializer, IntegrationViewSet @@ -3137,6 +3138,62 @@ def test_link_existing_auto_resolve_rejects_when_not_exactly_one(self, _name, in installation_id_param=None, ) + @patch("posthog.models.integration.GitHubIntegration.integration_from_installation_id") + def test_link_existing_with_installation_id_disambiguates_multiple(self, mock_from_install): + # With more than one org installation, auto-resolve is ambiguous; passing the chosen + # installation_id must link that specific installation instead of raising. + for installation_id in ("111", "222"): + team = Team.objects.create(organization=self.organization, name=f"Sibling {installation_id}") + Integration.objects.create( + team=team, + kind="github", + integration_id=installation_id, + config={"installation_id": installation_id}, + sensitive_config={"access_token": "ghs_sibling"}, + ) + mock_from_install.side_effect = lambda *args, **kwargs: self._team_github_integration() + + result = link_existing_team_github_integration( + user=self.user, + organization=self.organization, + team_id=self.team.pk, + source_team_id=None, + installation_id_param="222", + ) + + assert result is not None + assert mock_from_install.call_args.args[0] == "222" + assert mock_from_install.call_args.args[1] == self.team.pk + + def test_list_org_github_installations_dedupes_and_excludes_target_team(self): + # The picker lists one entry per distinct installation_id in the org, excluding the target + # team's own installation, with account metadata for display. + self._team_github_integration(installation_id="999") + first = Team.objects.create(organization=self.organization, name="Org Project") + Integration.objects.create( + team=first, + kind="github", + integration_id="111", + config={"installation_id": "111", "account": {"name": "acme", "type": "Organization"}}, + sensitive_config={"access_token": "ghs_a"}, + ) + # A second project on the same installation must collapse into a single entry. + second = Team.objects.create(organization=self.organization, name="Other Project") + Integration.objects.create( + team=second, + kind="github", + integration_id="111", + config={"installation_id": "111", "account": {"name": "acme", "type": "Organization"}}, + sensitive_config={"access_token": "ghs_a2"}, + ) + + installations = list_org_github_installations(organization=self.organization, exclude_team_id=self.team.pk) + + assert [installation["installation_id"] for installation in installations] == ["111"] + assert installations[0]["account_name"] == "acme" + assert installations[0]["account_type"] == "Organization" + assert installations[0]["source_team_id"] == first.pk + def test_cross_user_state_rejected_on_unified_callback(self, client: HttpClient): # State tokens are bound to a user via the pending-pointer cache key. # Another admin in the same team must not be able to finish a callback From b9ef13bdda638e03f23db5172b691a1c421e7fc7 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Thu, 30 Jul 2026 19:17:54 +0100 Subject: [PATCH 02/13] chore: update OpenAPI generated types --- .../frontend/generated/api.schemas.ts | 130 ++++++++++++++++++ .../integrations/frontend/generated/api.ts | 42 ++++++ products/integrations/mcp/tools.yaml | 3 + services/mcp/src/api/generated.ts | 130 ++++++++++++++++++ 4 files changed, 305 insertions(+) diff --git a/products/integrations/frontend/generated/api.schemas.ts b/products/integrations/frontend/generated/api.schemas.ts index 49626995e716..b12b892fa847 100644 --- a/products/integrations/frontend/generated/api.schemas.ts +++ b/products/integrations/frontend/generated/api.schemas.ts @@ -387,6 +387,32 @@ export interface LinearTeamsResponseApi { teams: LinearTeamApi[] } +export interface GitHubAvailableInstallationApi { + /** GitHub installation ID to pass to github/link_existing when linking this installation. */ + installation_id: string + /** + * GitHub account (organization or user) the installation belongs to, for display in the picker. + * @nullable + */ + account_name: string | null + /** + * GitHub account type, e.g. 'Organization' or 'User'. + * @nullable + */ + account_type: string | null + /** A project in the organization that already has this installation linked. */ + source_team_id: number +} + +export interface PaginatedGitHubAvailableInstallationListApi { + count: number + /** @nullable */ + next?: string | null + /** @nullable */ + previous?: string | null + results: GitHubAvailableInstallationApi[] +} + export interface GitHubLinkExistingRequestApi { /** * Sibling team in the same organization whose GitHub installation should be reused. @@ -700,3 +726,107 @@ export type IntegrationsGithubTeamsRetrieveParams = { */ search?: string } + +export type IntegrationsGithubAvailableInstallationsListParams = { + /** + * * `anthropic` - Anthropic + * * `apns` - Apple Push + * * `aws-s3` - Aws S3 + * * `azure-blob` - Azure Blob + * * `bing-ads` - Bing Ads + * * `clickup` - Clickup + * * `customerio-app` - Customerio App + * * `customerio-track` - Customerio Track + * * `customerio-webhook` - Customerio Webhook + * * `databricks` - Databricks + * * `email` - Email + * * `firebase` - Firebase + * * `github` - Github + * * `gitlab` - Gitlab + * * `google-ads` - Google Ads + * * `google-analytics` - Google Analytics + * * `google-cloud-service-account` - Google Cloud Service Account + * * `google-cloud-storage` - Google Cloud Storage + * * `google-pubsub` - Google Pubsub + * * `google-search-console` - Google Search Console + * * `google-sheets` - Google Sheets + * * `hubspot` - Hubspot + * * `intercom` - Intercom + * * `jira` - Jira + * * `linear` - Linear + * * `linkedin-ads` - Linkedin Ads + * * `meta-ads` - Meta Ads + * * `pardot` - Pardot + * * `pinterest-ads` - Pinterest Ads + * * `postgresql` - Postgresql + * * `reddit-ads` - Reddit Ads + * * `resend` - Resend + * * `s3-compatible` - S3 Compatible + * * `salesforce` - Salesforce + * * `slack` - Slack + * * `slack-posthog-code` - Slack Posthog Code + * * `snapchat` - Snapchat + * * `snowflake` - Snowflake + * * `stripe` - Stripe + * * `tiktok-ads` - Tiktok Ads + * * `twilio` - Twilio + * * `vercel` - Vercel + */ + kind?: IntegrationsGithubAvailableInstallationsListKind + /** + * Number of results to return per page. + */ + limit?: number + /** + * The initial index from which to return the results. + */ + offset?: number +} + +export type IntegrationsGithubAvailableInstallationsListKind = + (typeof IntegrationsGithubAvailableInstallationsListKind)[keyof typeof IntegrationsGithubAvailableInstallationsListKind] + +export const IntegrationsGithubAvailableInstallationsListKind = { + Anthropic: 'anthropic', + Apns: 'apns', + AwsS3: 'aws-s3', + AzureBlob: 'azure-blob', + BingAds: 'bing-ads', + Clickup: 'clickup', + CustomerioApp: 'customerio-app', + CustomerioTrack: 'customerio-track', + CustomerioWebhook: 'customerio-webhook', + Databricks: 'databricks', + Email: 'email', + Firebase: 'firebase', + Github: 'github', + Gitlab: 'gitlab', + GoogleAds: 'google-ads', + GoogleAnalytics: 'google-analytics', + GoogleCloudServiceAccount: 'google-cloud-service-account', + GoogleCloudStorage: 'google-cloud-storage', + GooglePubsub: 'google-pubsub', + GoogleSearchConsole: 'google-search-console', + GoogleSheets: 'google-sheets', + Hubspot: 'hubspot', + Intercom: 'intercom', + Jira: 'jira', + Linear: 'linear', + LinkedinAds: 'linkedin-ads', + MetaAds: 'meta-ads', + Pardot: 'pardot', + PinterestAds: 'pinterest-ads', + Postgresql: 'postgresql', + RedditAds: 'reddit-ads', + Resend: 'resend', + S3Compatible: 's3-compatible', + Salesforce: 'salesforce', + Slack: 'slack', + SlackPosthogCode: 'slack-posthog-code', + Snapchat: 'snapchat', + Snowflake: 'snowflake', + Stripe: 'stripe', + TiktokAds: 'tiktok-ads', + Twilio: 'twilio', + Vercel: 'vercel', +} as const diff --git a/products/integrations/frontend/generated/api.ts b/products/integrations/frontend/generated/api.ts index 4518df9996b7..95b0daecf5bc 100644 --- a/products/integrations/frontend/generated/api.ts +++ b/products/integrations/frontend/generated/api.ts @@ -21,6 +21,7 @@ import type { IntegrationAccessRequestResponseApi, IntegrationConfigApi, IntegrationsChannelsRetrieveParams, + IntegrationsGithubAvailableInstallationsListParams, IntegrationsGithubBranchesRetrieveParams, IntegrationsGithubReposRetrieveParams, IntegrationsGithubTeamsRetrieveParams, @@ -28,6 +29,7 @@ import type { JiraProjectsResponseApi, LinearTeamsResponseApi, OrganizationIntegrationApi, + PaginatedGitHubAvailableInstallationListApi, PaginatedIntegrationConfigListApi, PaginatedRoleExternalReferenceListApi, PatchedIntegrationConfigApi, @@ -669,6 +671,46 @@ export const integrationsDomainConnectCheckRetrieve = async ( }) } +export const getIntegrationsGithubAvailableInstallationsListUrl = ( + projectId: string, + params?: IntegrationsGithubAvailableInstallationsListParams +) => { + const normalizedParams = new URLSearchParams() + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : String(value)) + } + }) + + const stringifiedParams = normalizedParams.toString() + + return stringifiedParams.length > 0 + ? `/api/projects/${projectId}/integrations/github/available_installations/?${stringifiedParams}` + : `/api/projects/${projectId}/integrations/github/available_installations/` +} + +/** + * List the org's existing GitHub installations this project can reuse. + * + * A GitHub App installs once per organization, so a second project links an existing + * installation rather than reinstalling. This backs the picker: when the org has more than + * one installation, the client passes the chosen installation_id to github/link_existing. + */ +export const integrationsGithubAvailableInstallationsList = async ( + projectId: string, + params?: IntegrationsGithubAvailableInstallationsListParams, + options?: RequestInit +): Promise => { + return apiMutator( + getIntegrationsGithubAvailableInstallationsListUrl(projectId, params), + { + ...options, + method: 'GET', + } + ) +} + export const getIntegrationsGithubLinkExistingCreateUrl = (projectId: string) => { return `/api/projects/${projectId}/integrations/github/link_existing/` } diff --git a/products/integrations/mcp/tools.yaml b/products/integrations/mcp/tools.yaml index b212118b848a..b14e21141dad 100644 --- a/products/integrations/mcp/tools.yaml +++ b/products/integrations/mcp/tools.yaml @@ -100,6 +100,9 @@ tools: integrations-environment-mapping-partial-update: operation: integrations_environment_mapping_partial_update enabled: false + integrations-github-available-installations-list: + operation: integrations_github_available_installations_list + enabled: false integrations-github-branches-retrieve: operation: integrations_github_branches_retrieve enabled: false diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 86e0b5361bcc..75dbd3f65a4d 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -32480,6 +32480,23 @@ export namespace Schemas { trace_id: string; } + export interface GitHubAvailableInstallation { + /** GitHub installation ID to pass to github/link_existing when linking this installation. */ + installation_id: string; + /** + * GitHub account (organization or user) the installation belongs to, for display in the picker. + * @nullable + */ + account_name: string | null; + /** + * GitHub account type, e.g. 'Organization' or 'User'. + * @nullable + */ + account_type: string | null; + /** A project in the organization that already has this installation linked. */ + source_team_id: number; + } + export interface GitHubBranchesResponse { /** List of branch names */ branches: string[]; @@ -42726,6 +42743,15 @@ export namespace Schemas { results: FolderInstructionsVersion[]; } + export interface PaginatedGitHubAvailableInstallationList { + count: number; + /** @nullable */ + next?: string | null; + /** @nullable */ + previous?: string | null; + results: GitHubAvailableInstallation[]; + } + export interface PaginatedGroupUsageMetricList { count: number; /** @nullable */ @@ -78545,6 +78571,110 @@ export namespace Schemas { search?: string; }; + export type IntegrationsGithubAvailableInstallationsListParams = { + /** + * * `anthropic` - Anthropic + * * `apns` - Apple Push + * * `aws-s3` - Aws S3 + * * `azure-blob` - Azure Blob + * * `bing-ads` - Bing Ads + * * `clickup` - Clickup + * * `customerio-app` - Customerio App + * * `customerio-track` - Customerio Track + * * `customerio-webhook` - Customerio Webhook + * * `databricks` - Databricks + * * `email` - Email + * * `firebase` - Firebase + * * `github` - Github + * * `gitlab` - Gitlab + * * `google-ads` - Google Ads + * * `google-analytics` - Google Analytics + * * `google-cloud-service-account` - Google Cloud Service Account + * * `google-cloud-storage` - Google Cloud Storage + * * `google-pubsub` - Google Pubsub + * * `google-search-console` - Google Search Console + * * `google-sheets` - Google Sheets + * * `hubspot` - Hubspot + * * `intercom` - Intercom + * * `jira` - Jira + * * `linear` - Linear + * * `linkedin-ads` - Linkedin Ads + * * `meta-ads` - Meta Ads + * * `pardot` - Pardot + * * `pinterest-ads` - Pinterest Ads + * * `postgresql` - Postgresql + * * `reddit-ads` - Reddit Ads + * * `resend` - Resend + * * `s3-compatible` - S3 Compatible + * * `salesforce` - Salesforce + * * `slack` - Slack + * * `slack-posthog-code` - Slack Posthog Code + * * `snapchat` - Snapchat + * * `snowflake` - Snowflake + * * `stripe` - Stripe + * * `tiktok-ads` - Tiktok Ads + * * `twilio` - Twilio + * * `vercel` - Vercel + */ + kind?: IntegrationsGithubAvailableInstallationsListKind; + /** + * Number of results to return per page. + */ + limit?: number; + /** + * The initial index from which to return the results. + */ + offset?: number; + }; + + export type IntegrationsGithubAvailableInstallationsListKind = typeof IntegrationsGithubAvailableInstallationsListKind[keyof typeof IntegrationsGithubAvailableInstallationsListKind]; + + + export const IntegrationsGithubAvailableInstallationsListKind = { + Anthropic: 'anthropic', + Apns: 'apns', + AwsS3: 'aws-s3', + AzureBlob: 'azure-blob', + BingAds: 'bing-ads', + Clickup: 'clickup', + CustomerioApp: 'customerio-app', + CustomerioTrack: 'customerio-track', + CustomerioWebhook: 'customerio-webhook', + Databricks: 'databricks', + Email: 'email', + Firebase: 'firebase', + Github: 'github', + Gitlab: 'gitlab', + GoogleAds: 'google-ads', + GoogleAnalytics: 'google-analytics', + GoogleCloudServiceAccount: 'google-cloud-service-account', + GoogleCloudStorage: 'google-cloud-storage', + GooglePubsub: 'google-pubsub', + GoogleSearchConsole: 'google-search-console', + GoogleSheets: 'google-sheets', + Hubspot: 'hubspot', + Intercom: 'intercom', + Jira: 'jira', + Linear: 'linear', + LinkedinAds: 'linkedin-ads', + MetaAds: 'meta-ads', + Pardot: 'pardot', + PinterestAds: 'pinterest-ads', + Postgresql: 'postgresql', + RedditAds: 'reddit-ads', + Resend: 'resend', + S3Compatible: 's3-compatible', + Salesforce: 'salesforce', + Slack: 'slack', + SlackPosthogCode: 'slack-posthog-code', + Snapchat: 'snapchat', + Snowflake: 'snowflake', + Stripe: 'stripe', + TiktokAds: 'tiktok-ads', + Twilio: 'twilio', + Vercel: 'vercel', + } as const; + export type JsSnippetResolveRetrieve200 = { [key: string]: unknown }; export type JsSnippetVersionRetrieve200 = { [key: string]: unknown }; From 70a3513c8e8076419e19cfac8193e85319fc983b Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Thu, 30 Jul 2026 19:17:57 +0100 Subject: [PATCH 03/13] chore(integrations): use generated GitHubAvailableInstallationApi type Replace the hand-written GitHubAvailableInstallation interface in lib/api with the generated GitHubAvailableInstallationApi, and regenerate the kea-typegen blocks for integrationsLogic. Generated-By: PostHog Code Task-Id: ae6ad9b6-969c-4e53-9d09-4d2086ea6bf5 --- frontend/src/lib/api.ts | 12 +---- .../src/lib/integrations/integrationsLogic.ts | 50 ++++++++++--------- 2 files changed, 29 insertions(+), 33 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ce503e2ac3b0..7536a7e47449 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -233,6 +233,7 @@ import type { SymbolSetOrder } from 'products/error_tracking/frontend/scenes/Err import type { ErrorTrackingRecommendation } from 'products/error_tracking/frontend/scenes/ErrorTrackingScene/tabs/recommendations/types' import type { CopyFlagsResponseApi } from 'products/feature_flags/frontend/generated/api.schemas' import type { + GitHubAvailableInstallationApi, GitHubBranchesResponseApi, GitHubReposResponseApi, } from 'products/integrations/frontend/generated/api.schemas' @@ -293,15 +294,6 @@ import type { ProductIntentProperties } from './utils/product-intents' export type CheckboxValueType = string | number | boolean -// Mirrors GitHubAvailableInstallationSerializer. Replace with the generated `GitHubAvailableInstallationApi` -// once `hogli build:openapi` has been run against the new github/available_installations endpoint. -export interface GitHubAvailableInstallation { - installation_id: string - account_name: string | null - account_type: string | null - source_team_id: number -} - const PAGINATION_DEFAULT_MAX_PAGES = 10 export interface PaginatedResponse { @@ -6323,7 +6315,7 @@ const api = { ): Promise { return await new ApiRequest().integrations(teamId).withAction('github/link_existing').create({ data }) }, - async githubAvailableInstallations(teamId?: TeamType['id']): Promise { + async githubAvailableInstallations(teamId?: TeamType['id']): Promise { return await new ApiRequest().integrations(teamId).withAction('github/available_installations').get() }, async githubOAuthAuthorize( diff --git a/frontend/src/lib/integrations/integrationsLogic.ts b/frontend/src/lib/integrations/integrationsLogic.ts index a2de5f9b39f5..d09f7b8b4e70 100644 --- a/frontend/src/lib/integrations/integrationsLogic.ts +++ b/frontend/src/lib/integrations/integrationsLogic.ts @@ -4,7 +4,7 @@ import { router, urlToAction } from 'kea-router' import { LemonDialog, lemonToast } from '@posthog/lemon-ui' -import api, { ApiError, getCookie, GitHubAvailableInstallation } from 'lib/api' +import api, { ApiError, getCookie } from 'lib/api' import { globalSetupLogic } from 'lib/components/ProductSetup' import { describeGithubSetupError, GITHUB_INSTALL_PENDING_MESSAGE } from 'lib/integrations/githubSetupErrors' import { describeOAuthCallbackError } from 'lib/integrations/oauthCallbackErrors' @@ -20,7 +20,11 @@ import { integrationsGithubReposRetrieve, integrationsRequestAccessCreate, } from 'products/integrations/frontend/generated/api' -import type { GitHubRepoApi, IntegrationKindEnumApi } from 'products/integrations/frontend/generated/api.schemas' +import type { + GitHubAvailableInstallationApi, + GitHubRepoApi, + IntegrationKindEnumApi, +} from 'products/integrations/frontend/generated/api.schemas' import { ChannelType } from 'products/workflows/frontend/Channels/MessageChannels' import type { AvailableSetupTaskIdsEnumApi } from '../../generated/core/api.schemas' @@ -86,11 +90,11 @@ export interface integrationsLogicValues { | 'vercel' )[] ) => IntegrationType[] + githubAvailableInstallations: GitHubAvailableInstallationApi[] | null + githubAvailableInstallationsLoading: boolean githubIntegrations: IntegrationType[] githubRepositories: Record githubRepositoriesLoading: boolean - githubAvailableInstallations: GitHubAvailableInstallation[] | null - githubAvailableInstallationsLoading: boolean integrations: IntegrationType[] | null integrationsLoading: boolean linkedGithubInstallation: IntegrationType | null @@ -164,7 +168,7 @@ export interface integrationsLogicActions { | 'vercel' searchParams: any } - linkExistingGithubInstallation: (installationId?: string) => string | undefined + linkExistingGithubInstallation: (installationId?: string) => string linkExistingGithubInstallationFailure: ( error: string, errorObject?: any @@ -174,25 +178,10 @@ export interface integrationsLogicActions { } linkExistingGithubInstallationSuccess: ( linkedGithubInstallation: IntegrationType, - payload?: any + payload?: string ) => { linkedGithubInstallation: IntegrationType - payload?: any - } - loadGithubAvailableInstallations: () => any - loadGithubAvailableInstallationsFailure: ( - error: string, - errorObject?: any - ) => { - error: string - errorObject?: any - } - loadGithubAvailableInstallationsSuccess: ( - githubAvailableInstallations: GitHubAvailableInstallation[], - payload?: any - ) => { - githubAvailableInstallations: GitHubAvailableInstallation[] - payload?: any + payload?: string } loadGitHubRepositories: (integrationId: number) => { integrationId: number @@ -216,6 +205,21 @@ export interface integrationsLogicActions { integrationId: number repositories: GitHubRepoApi[] } + loadGithubAvailableInstallations: () => any + loadGithubAvailableInstallationsFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + loadGithubAvailableInstallationsSuccess: ( + githubAvailableInstallations: GitHubAvailableInstallationApi[], + payload?: any + ) => { + githubAvailableInstallations: GitHubAvailableInstallationApi[] + payload?: any + } loadIntegrations: () => any loadIntegrationsFailure: ( error: string, @@ -849,7 +853,7 @@ export const integrationsLogic = kea([ }, ], githubAvailableInstallations: [ - null as GitHubAvailableInstallation[] | null, + null as GitHubAvailableInstallationApi[] | null, { loadGithubAvailableInstallations: async () => { return await api.integrations.githubAvailableInstallations() From 0d70231f48431e997fb02ca55fd514ec01ffe323 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Thu, 30 Jul 2026 19:17:59 +0100 Subject: [PATCH 04/13] fix(integrations): use dropdown for GitHub installations --- .../components/Integrations.test.tsx | 39 +++++++++ .../integrations/components/Integrations.tsx | 79 +++++++++++++------ 2 files changed, 94 insertions(+), 24 deletions(-) create mode 100644 frontend/src/scenes/integrations/components/Integrations.test.tsx diff --git a/frontend/src/scenes/integrations/components/Integrations.test.tsx b/frontend/src/scenes/integrations/components/Integrations.test.tsx new file mode 100644 index 000000000000..553e61781214 --- /dev/null +++ b/frontend/src/scenes/integrations/components/Integrations.test.tsx @@ -0,0 +1,39 @@ +import '@testing-library/jest-dom' + +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import type { GitHubAvailableInstallationApi } from 'products/integrations/frontend/generated/api.schemas' + +import { GitHubInstallationLink } from './Integrations' + +describe('GitHubInstallationLink', () => { + it('uses one menu trigger when multiple GitHub installations are available', async () => { + const user = userEvent.setup() + const onLink = jest.fn() + const installations: GitHubAvailableInstallationApi[] = [ + { + installation_id: '101', + account_name: 'PostHog', + account_type: 'Organization', + source_team_id: 1, + }, + { + installation_id: '202', + account_name: 'Hedgebox', + account_type: 'Organization', + source_team_id: 2, + }, + ] + + render() + + expect(screen.getAllByText('Link existing installation')).toHaveLength(1) + expect(screen.queryByText('PostHog')).not.toBeInTheDocument() + + await user.click(screen.getByText('Link existing installation')) + await user.click(await screen.findByText('PostHog')) + + expect(onLink).toHaveBeenCalledWith('101') + }) +}) diff --git a/frontend/src/scenes/integrations/components/Integrations.tsx b/frontend/src/scenes/integrations/components/Integrations.tsx index 389fb551781f..936771a9c523 100644 --- a/frontend/src/scenes/integrations/components/Integrations.tsx +++ b/frontend/src/scenes/integrations/components/Integrations.tsx @@ -1,7 +1,9 @@ import { useActions, useValues } from 'kea' import { PropsWithChildren, useMemo, useState } from 'react' +import { IconChevronDown } from '@posthog/icons' import { LemonButton } from '@posthog/lemon-ui' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@posthog/quill' import api from 'lib/api' import { integrationsLogic } from 'lib/integrations/integrationsLogic' @@ -12,6 +14,8 @@ import { urls } from 'scenes/urls' import { IntegrationKind, IntegrationType } from '~/types' +import type { GitHubAvailableInstallationApi } from 'products/integrations/frontend/generated/api.schemas' + export function GitLabIntegration(): JSX.Element { const [isOpen, setIsOpen] = useState(false) return ( @@ -41,8 +45,6 @@ export function GithubIntegration({ next }: { next?: string }): JSX.Element { }) const installations = githubAvailableInstallations ?? [] - // A GitHub App installs once per org, so reuse an existing installation rather than reinstall — - // but only when this project has none and the org has one to link. const canLinkExisting = githubIntegrations.length === 0 && installations.length > 0 const multipleInstallations = installations.length > 1 @@ -53,32 +55,18 @@ export function GithubIntegration({ next }: { next?: string }): JSX.Element { Connect organization - {canLinkExisting && - (multipleInstallations ? ( - installations.map((installation) => ( - linkExistingGithubInstallation(installation.installation_id)} - > - Link {installation.account_name ?? `installation ${installation.installation_id}`} - - )) - ) : ( - linkExistingGithubInstallation()} - > - Link existing installation - - ))} + {canLinkExisting && ( + + )}
{canLinkExisting && (

{multipleInstallations - ? 'Your organization has more than one PostHog GitHub App installation. A GitHub App installs once per organization, so pick the one to connect to this project.' + ? 'Choose an existing GitHub installation to connect to this project.' : 'Already installed the PostHog GitHub App for another project in this organization? A GitHub App installs once per organization, so use "Link existing installation" to connect it here instead of reinstalling.'}

)} @@ -87,6 +75,49 @@ export function GithubIntegration({ next }: { next?: string }): JSX.Element { ) } +export function GitHubInstallationLink({ + installations, + loading, + onLink, +}: { + installations: GitHubAvailableInstallationApi[] + loading: boolean + onLink: (installationId?: string) => void +}): JSX.Element | null { + if (installations.length === 0) { + return null + } + + if (installations.length === 1) { + return ( + onLink()}> + Link existing installation + + ) + } + + return ( + + } />} + > + Link existing installation + + + {installations.map((installation) => ( + onLink(installation.installation_id)} + > + {installation.account_name ?? `Installation ${installation.installation_id}`} + + ))} + + + ) +} + export function JiraIntegration({ next }: { next?: string }): JSX.Element { return } From 5690e3c7977b4a01b1158aa3b3703e2556246667 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:43:19 +0000 Subject: [PATCH 05/13] chore(visual): update storybook baselines 10 updated Run: 5739f000-0af9-4540-ba57-75d45dfed730 Co-authored-by: joshsny <7135900+joshsny@users.noreply.github.com> --- frontend/snapshots.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/snapshots.yml b/frontend/snapshots.yml index 4394db8d9bd8..86cc450dab06 100644 --- a/frontend/snapshots.yml +++ b/frontend/snapshots.yml @@ -6971,9 +6971,9 @@ snapshots: scenes-app-settings-environment--settings-environment-details--light: hash: v1.k794b7964.d4b5e0030757796876fa680f389ca76201f098f3b1b65eb303ca73b5e1be6ae4.nm2ebJO-70lgkubc-wOB4EFtPnO-885kq4I93mt0TNs scenes-app-settings-environment--settings-environment-error-tracking--dark: - hash: v1.k794b7964.8c31cf4377cc4aa217ef13e05c56ccfa53994f780b73471c5d0982de4fa78d64.347ACdUg244bCzms7KAIMTpLv1WjkTahcO-uJXTkiyg + hash: v1.k794b7964.d9d299561ca437a5157edf37c4758a22d0d3db4844b78c5b23d2ad57fefeed46.1WzOoH8UOJjf6a6xfjBQfArtLbWmlU9q2VgRKExoisc scenes-app-settings-environment--settings-environment-error-tracking--light: - hash: v1.k794b7964.26a597269ab5fffa13b4ee537a28a9dbb6dea5b74d2ba7c476792d89fea18f2e.TkgSHX0A7P-GqH7EyapdL8uq40FkIW2sq0tdL5EouTc + hash: v1.k794b7964.98d311695dbaf5a8fe7e533170c3194f4a16a3221b62bd50a7a0d52de7ffb87a.nL8nCyaLMIlm4UNM11v_EYEgfHJwOgT1yk_ar2DduhM scenes-app-settings-environment--settings-environment-error-tracking-configuration--dark: hash: v1.k794b7964.56ec7a0f848a210db0b28301b8de7a858daa47b4075114d92e49dcc314e53771.eBsNSzM5P7mAshc2EMJhB6A8K6MuESVzMaT_zktP0jI scenes-app-settings-environment--settings-environment-error-tracking-configuration--light: @@ -6987,9 +6987,9 @@ snapshots: scenes-app-settings-environment--settings-environment-heatmaps--light: hash: v1.k794b7964.05c858527b6a3deecc54799e4d4fad6c2b88ff83a26dc0baadff3448bf6635b4.u3Xak3I8OzaYGlO_KlLOvHMh7sWvmjOVDrKURMJBQLs scenes-app-settings-environment--settings-environment-integrations--dark: - hash: v1.k794b7964.81a9ac1dae6f0520d24618ca1164d13febe35d137184e985a344dcd1632e0cec.fQFE2wr9G3dtsonE4swvWZ_5-LveeCtXd6lkJAqR2pg + hash: v1.k794b7964.c702b588bdda7c56430d8adf4a896345977abcf0a562e0e1bddf88238496770b.B_JN44u0kV7No27lpvJOOTBN7urhc6zcJ6r3kTOLFJk scenes-app-settings-environment--settings-environment-integrations--light: - hash: v1.k794b7964.1934174417b65042ff2e3e967e833f604198ee713c7f7711f67b57646d9326a1.s9GyCmxzjPtnJ5aQsAbXkjY0WFJxqavzA2JTDDgFi34 + hash: v1.k794b7964.f249ac0b4d1984c3d0259394fef46e89186d039a4089c7d2acb19922e1d939d5.T0FKR0TfOB2Flt5kqA4HboFgQlUHjdI92AdocREcaoQ scenes-app-settings-environment--settings-environment-marketing-analytics--dark: hash: v1.k794b7964.72d483d5eeffbd82927b8b748abb1f6a624ed157f86db39b7c231b26e1c0a574.DdQZfV_I433UB3bh4bmxnivledUaDRg0EVOIWqWfTOQ scenes-app-settings-environment--settings-environment-marketing-analytics--light: @@ -7007,9 +7007,9 @@ snapshots: scenes-app-settings-environment--settings-environment-product-analytics--light: hash: v1.k794b7964.70bf7959ecd4f39a1ac0d069beec0f6cdaf24d9afaee70e70b4cf75619f56de5.xQCvIAWzmRCUGafz6moNuItTspGJA6dwjKLb3mE16Uk scenes-app-settings-environment--settings-environment-replay--dark: - hash: v1.k794b7964.420f87c12f0437816ce82f7e13a45851e78a6e84198cbd81262c964a90bdc47e.CaLkZAptUT5gtkAbv8PMsSfuCUSfKnhks4ojIwUks0E + hash: v1.k794b7964.21bb720ce05c578a8845a479d05d784ba1d53ddcb95c69394affbf938b1a7965.9i1A3a_KRw2QEMXpVyo4pvn14VQVGnQmsJK-aVTCZEY scenes-app-settings-environment--settings-environment-replay--light: - hash: v1.k794b7964.7eff7d4f78ea0ebea1712c75a344af72769a1370cb89df2207419498ec5ca613.BYQaF3dooPNLprvFYODHlQwcc6VmfgnZ81Td6MzcOd0 + hash: v1.k794b7964.e85b294e4ef70bcdac34028f9695d0141f1d3048df64dffc4efe44db0d5a3bad.NNpko46SP0bM093ww1OUryXTQvWWrrprlf4DJTjRrkY scenes-app-settings-environment--settings-environment-revenue-analytics--dark: hash: v1.k794b7964.1b59813d53eb3cf7f0505a250ff6dcac0c25785011679e74420d389dabe25582.Gn5HB_rvDNmwFNIlRts1ZZ17hRhFJ88yILUevtuwy0g scenes-app-settings-environment--settings-environment-revenue-analytics--light: @@ -7071,17 +7071,17 @@ snapshots: scenes-app-settings-project--settings-project-details--light: hash: v1.k794b7964.92d131dadd962625b5dfdd78a2cb9c42017b230b1c39f9ae1b293e8f6e9fe788.ufkrkTmgF764iFDi-uaHriqBvleJci3OVpjBAwJxiUw scenes-app-settings-project--settings-project-integrations--dark: - hash: v1.k794b7964.81a9ac1dae6f0520d24618ca1164d13febe35d137184e985a344dcd1632e0cec.oySHN778JskP5Yz0hvSlx4gjJijO83k8OJLXyCNqb1w + hash: v1.k794b7964.c702b588bdda7c56430d8adf4a896345977abcf0a562e0e1bddf88238496770b.1kRxkbRjHvzKooz7gjEbDDb3eVs_NFoQLI7EJGWU7d8 scenes-app-settings-project--settings-project-integrations--light: - hash: v1.k794b7964.1934174417b65042ff2e3e967e833f604198ee713c7f7711f67b57646d9326a1.DxQZ3xs0WrHP48Y9r13SVaHcJsN4h1KJVK-NSPUSXpg + hash: v1.k794b7964.f249ac0b4d1984c3d0259394fef46e89186d039a4089c7d2acb19922e1d939d5.jJ02CU9zcmLg0Itz38r89UxL3MY_8Rnd-KddWUMrkP8 scenes-app-settings-project--settings-project-product-analytics--dark: hash: v1.k794b7964.925784616ac6ad5cb556351ede1916440226c9089930265fc96df7e14863046c.6iXpmU_M40nsD_JokXYH4Ey-T5YaQsEB1jGl35jRBgo scenes-app-settings-project--settings-project-product-analytics--light: hash: v1.k794b7964.527360edf55b268b83ba976288336721af6da9af6088605669a4395b5a9b7a20.qnXEVYP868iG2Q0bj-z9_tORjlspkGdyJEMj5GDuG-8 scenes-app-settings-project--settings-project-replay--dark: - hash: v1.k794b7964.420f87c12f0437816ce82f7e13a45851e78a6e84198cbd81262c964a90bdc47e.MbG5FcxBuIFHRdrwoHEbyKJb_Pz8doVuDwvq1AaJeXM + hash: v1.k794b7964.236164910b062612c43d08adf4badb434a9040029b3863f00b2dd3e42b7dd6ea.MA7G3gr8yO98zhL7mvkJH2peqZi1qKQ4oi95zldIjD0 scenes-app-settings-project--settings-project-replay--light: - hash: v1.k794b7964.7eff7d4f78ea0ebea1712c75a344af72769a1370cb89df2207419498ec5ca613.MmWu9UBGKmS-G4nQoVPXkW1Po6RDl327aG2XZYgKm-c + hash: v1.k794b7964.e85b294e4ef70bcdac34028f9695d0141f1d3048df64dffc4efe44db0d5a3bad._O2pBTlPs63Agtc0ofCCJVnt1KyJOezEe3-llnsuGQE scenes-app-settings-project--settings-project-surveys--dark: hash: v1.k794b7964.116b8618e7355d13ff8ddc83c5095b98edd1bca44d8326d7a05c2922e967b0d8.mYqdrV9UEmN1USMnHw5MMm3DWaOk8_s_BNmFP-aRMFU scenes-app-settings-project--settings-project-surveys--light: From 0431680c879d3ab8202f393f73c74e7fc2739a0b Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Thu, 30 Jul 2026 19:46:52 +0100 Subject: [PATCH 06/13] fix(integrations): return available installations as an object, not a bare list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile flagged that the many=True response annotation generates a paginated API contract (a `results` field) while the action returned a bare JSON array, so generated consumers couldn't enumerate installations. Wrap the list in an { installations: [...] } object — matching the github_repos response shape — so the generated types and MCP tool describe the real payload. CI regenerates the OpenAPI/MCP types from the serializer. Generated-By: PostHog Code Task-Id: ae6ad9b6-969c-4e53-9d09-4d2086ea6bf5 --- frontend/src/lib/api.ts | 6 +++++- posthog/api/integration.py | 11 +++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7536a7e47449..41f1343b5e74 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -6316,7 +6316,11 @@ const api = { return await new ApiRequest().integrations(teamId).withAction('github/link_existing').create({ data }) }, async githubAvailableInstallations(teamId?: TeamType['id']): Promise { - return await new ApiRequest().integrations(teamId).withAction('github/available_installations').get() + const response = await new ApiRequest() + .integrations(teamId) + .withAction('github/available_installations') + .get() + return response.installations }, async githubOAuthAuthorize( data: { diff --git a/posthog/api/integration.py b/posthog/api/integration.py index 7da84a27ee6d..561fa31ab7f8 100644 --- a/posthog/api/integration.py +++ b/posthog/api/integration.py @@ -919,6 +919,13 @@ class GitHubAvailableInstallationSerializer(serializers.Serializer): ) +class GitHubAvailableInstallationsResponseSerializer(serializers.Serializer): + installations = GitHubAvailableInstallationSerializer( + many=True, + help_text="Distinct GitHub installations in the organization available to link to this project.", + ) + + class GitHubOAuthAuthorizeRequestSerializer(serializers.Serializer): installation_id = serializers.CharField( required=False, @@ -1570,7 +1577,7 @@ def github_prepare_callback(self, request: Request, *args: Any, **kwargs: Any) - ) return Response(status=204) - @extend_schema(responses={200: GitHubAvailableInstallationSerializer(many=True)}) + @extend_schema(responses={200: GitHubAvailableInstallationsResponseSerializer}) @action(methods=["GET"], detail=False, url_path="github/available_installations") def github_available_installations(self, request: Request, *args: Any, **kwargs: Any) -> Response: """List the org's existing GitHub installations this project can reuse. @@ -1583,7 +1590,7 @@ def github_available_installations(self, request: Request, *args: Any, **kwargs: organization=self.organization, exclude_team_id=self.team_id, ) - return Response(GitHubAvailableInstallationSerializer(installations, many=True).data) + return Response({"installations": GitHubAvailableInstallationSerializer(installations, many=True).data}) @extend_schema( request=GitHubLinkExistingRequestSerializer, From 3188e21facc5c591830876396ce0e49ddd197a0f Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Thu, 30 Jul 2026 20:17:41 +0100 Subject: [PATCH 07/13] fix(integrations): gate existing-installation reuse on source-project access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Listing and linking an existing GitHub installation only checked org membership plus target-team admin, never whether the requester could access the source project the installation lives on. Because user.teams honours project-based permissioning, an org member who admins the target project but is locked out of a private sibling could enumerate that sibling's installation and link it (and its repositories) into their project. Restrict both list_org_github_installations and link_existing_team_github_integration to source teams in the requester's accessible teams, so the picker never surfaces — and the link endpoint never reuses — an installation the user couldn't otherwise reach. Org admins/owners keep implicit access to all projects, so the legitimate cross-project reuse flow is unchanged. Generated-By: PostHog Code Task-Id: 38be48ca-47ba-42b9-9b97-18e666c96270 --- posthog/api/github_callback/team_services.py | 33 +++++++++++++- posthog/api/integration.py | 1 + posthog/api/test/test_integration.py | 46 +++++++++++++++++++- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/posthog/api/github_callback/team_services.py b/posthog/api/github_callback/team_services.py index 8e0fb736a343..57f68debfff0 100644 --- a/posthog/api/github_callback/team_services.py +++ b/posthog/api/github_callback/team_services.py @@ -399,6 +399,16 @@ def authenticated_drf_request(http_request: HttpRequest) -> Request: return cast(Request, drf_request) +def _accessible_org_team_ids(user: User, organization: Organization) -> set[int]: + """Team ids in ``organization`` that ``user`` may actually access. + + ``user.teams`` already honours project-based permissioning (private projects, RBAC roles, + org admin/owner implicit access), so this is the source-project access boundary that gates + which installations a user can discover and reuse. + """ + return set(user.teams.filter(organization_id=organization.id).values_list("id", flat=True)) + + def link_existing_team_github_integration( *, user: User, @@ -472,6 +482,16 @@ def link_existing_team_github_integration( code=GITHUB_LINK_EXISTING_ERROR_ORPHAN_INSTALLATION, ) + # The source project is the one whose GitHub access we're reusing, so the requester must be able + # to access it — target-team admin alone is not enough. Without this, an org member who admins the + # target project but is locked out of a private sibling could pull that sibling's installation + # (and its repositories) in by supplying its source_team_id/installation_id. + if source.team_id not in _accessible_org_team_ids(user, organization): + raise ValidationError( + "No team in your organization has this GitHub installation linked", + code=GITHUB_LINK_EXISTING_ERROR_ORPHAN_INSTALLATION, + ) + installation_id = (source.config or {}).get("installation_id") if not installation_id: raise ValidationError("Source integration is missing installation_id") @@ -493,18 +513,27 @@ def link_existing_team_github_integration( def list_org_github_installations( *, + user: User, organization: Organization, exclude_team_id: int | None = None, ) -> list[dict[str, Any]]: - """List the distinct GitHub App installations already linked anywhere in ``organization``. + """List the distinct GitHub App installations ``user`` may reuse within ``organization``. A GitHub App installs once per org, so when an org has more than one installation the caller can't rely on the single-install auto-resolve path in ``link_existing_team_github_integration``. This enumerates the installations so the UI can offer a picker and pass an explicit ``installation_id``. The first integration seen for each installation id (deterministic ``order_by("id")``) provides the representative account metadata and source team. + + Only installations linked to source projects the user can access are returned — mirroring the + access boundary enforced in ``link_existing_team_github_integration`` so the picker never + surfaces an installation the user couldn't actually link. """ - org_github = Integration.objects.filter(team__organization_id=organization.id, kind="github") + org_github = Integration.objects.filter( + team__organization_id=organization.id, + team_id__in=_accessible_org_team_ids(user, organization), + kind="github", + ) if exclude_team_id is not None: org_github = org_github.exclude(team_id=exclude_team_id) org_github = org_github.order_by("id") diff --git a/posthog/api/integration.py b/posthog/api/integration.py index 561fa31ab7f8..f97468911220 100644 --- a/posthog/api/integration.py +++ b/posthog/api/integration.py @@ -1587,6 +1587,7 @@ def github_available_installations(self, request: Request, *args: Any, **kwargs: one installation, the client passes the chosen installation_id to github/link_existing. """ installations = list_org_github_installations( + user=cast(User, request.user), organization=self.organization, exclude_team_id=self.team_id, ) diff --git a/posthog/api/test/test_integration.py b/posthog/api/test/test_integration.py index ed338eacbf7d..973d7577f81f 100644 --- a/posthog/api/test/test_integration.py +++ b/posthog/api/test/test_integration.py @@ -21,6 +21,7 @@ from posthog.api.github_callback.state import store_unified_authorize_state from posthog.api.github_callback.team_services import ( + GITHUB_LINK_EXISTING_ERROR_ORPHAN_INSTALLATION, GITHUB_LINK_EXISTING_ERROR_PERSONAL_GITHUB_REQUIRED, authorize_link_existing_installation, link_existing_team_github_integration, @@ -28,6 +29,7 @@ ) from posthog.api.github_callback.types import FlowKind, GitHubAuthorizeState from posthog.api.integration import IntegrationSerializer, IntegrationViewSet +from posthog.constants import AvailableFeature from posthog.models.integration import ( ERROR_TOKEN_REFRESH_FAILED, GITHUB_REPOSITORY_REFRESH_COOLDOWN_SECONDS, @@ -55,6 +57,8 @@ from products.cdp.backend.models.hog_function_template import HogFunctionTemplate from products.workflows.backend.models import HogFlow +from ee.models.rbac.access_control import AccessControl + class TestSlackIntegration: @pytest.fixture(autouse=True) @@ -3187,13 +3191,53 @@ def test_list_org_github_installations_dedupes_and_excludes_target_team(self): sensitive_config={"access_token": "ghs_a2"}, ) - installations = list_org_github_installations(organization=self.organization, exclude_team_id=self.team.pk) + installations = list_org_github_installations( + user=self.user, organization=self.organization, exclude_team_id=self.team.pk + ) assert [installation["installation_id"] for installation in installations] == ["111"] assert installations[0]["account_name"] == "acme" assert installations[0]["account_type"] == "Organization" assert installations[0]["source_team_id"] == first.pk + def test_link_existing_rejects_installation_from_inaccessible_source_project(self): + # A user who admins the target project but is locked out of a private sibling must not be able + # to discover or reuse that sibling's installation — target-team admin is not access to the + # source project. Without the source-team access boundary this both leaks the installation in + # the picker and links its repositories into the target. + self.organization.available_product_features = [ + {"key": AvailableFeature.ACCESS_CONTROL, "name": AvailableFeature.ACCESS_CONTROL}, + ] + self.organization.save() + member = User.objects.create_and_join( + self.organization, "outsider@posthog.com", "test", level=OrganizationMembership.Level.MEMBER + ) + private = Team.objects.create(organization=self.organization, name="Private Project") + AccessControl.objects.create(team=private, resource="project", access_level="none") + Integration.objects.create( + team=private, + kind="github", + integration_id="777", + config={"installation_id": "777", "account": {"name": "secret", "type": "Organization"}}, + sensitive_config={"access_token": "ghs_private"}, + ) + + installations = list_org_github_installations( + user=member, organization=self.organization, exclude_team_id=self.team.pk + ) + assert installations == [] + + with pytest.raises(ValidationError) as exc_info: + link_existing_team_github_integration( + user=member, + organization=self.organization, + team_id=self.team.pk, + source_team_id=None, + installation_id_param="777", + ) + codes = exc_info.value.get_codes() + assert isinstance(codes, list) and GITHUB_LINK_EXISTING_ERROR_ORPHAN_INSTALLATION in codes + def test_cross_user_state_rejected_on_unified_callback(self, client: HttpClient): # State tokens are bound to a user via the pending-pointer cache key. # Another admin in the same team must not be able to finish a callback From 9692669d8e147cb9f5769a877ac73193bfdf297f Mon Sep 17 00:00:00 2001 From: "tests-posthog[bot]" <250237707+tests-posthog[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:22:14 +0000 Subject: [PATCH 08/13] chore: update OpenAPI generated types --- .../frontend/generated/api.schemas.ts | 114 +---------------- .../integrations/frontend/generated/api.ts | 31 ++--- products/integrations/mcp/tools.yaml | 4 +- services/mcp/src/api/generated.ts | 118 +----------------- 4 files changed, 17 insertions(+), 250 deletions(-) diff --git a/products/integrations/frontend/generated/api.schemas.ts b/products/integrations/frontend/generated/api.schemas.ts index b12b892fa847..66df8664c2a3 100644 --- a/products/integrations/frontend/generated/api.schemas.ts +++ b/products/integrations/frontend/generated/api.schemas.ts @@ -404,13 +404,9 @@ export interface GitHubAvailableInstallationApi { source_team_id: number } -export interface PaginatedGitHubAvailableInstallationListApi { - count: number - /** @nullable */ - next?: string | null - /** @nullable */ - previous?: string | null - results: GitHubAvailableInstallationApi[] +export interface GitHubAvailableInstallationsResponseApi { + /** Distinct GitHub installations in the organization available to link to this project. */ + installations: GitHubAvailableInstallationApi[] } export interface GitHubLinkExistingRequestApi { @@ -726,107 +722,3 @@ export type IntegrationsGithubTeamsRetrieveParams = { */ search?: string } - -export type IntegrationsGithubAvailableInstallationsListParams = { - /** - * * `anthropic` - Anthropic - * * `apns` - Apple Push - * * `aws-s3` - Aws S3 - * * `azure-blob` - Azure Blob - * * `bing-ads` - Bing Ads - * * `clickup` - Clickup - * * `customerio-app` - Customerio App - * * `customerio-track` - Customerio Track - * * `customerio-webhook` - Customerio Webhook - * * `databricks` - Databricks - * * `email` - Email - * * `firebase` - Firebase - * * `github` - Github - * * `gitlab` - Gitlab - * * `google-ads` - Google Ads - * * `google-analytics` - Google Analytics - * * `google-cloud-service-account` - Google Cloud Service Account - * * `google-cloud-storage` - Google Cloud Storage - * * `google-pubsub` - Google Pubsub - * * `google-search-console` - Google Search Console - * * `google-sheets` - Google Sheets - * * `hubspot` - Hubspot - * * `intercom` - Intercom - * * `jira` - Jira - * * `linear` - Linear - * * `linkedin-ads` - Linkedin Ads - * * `meta-ads` - Meta Ads - * * `pardot` - Pardot - * * `pinterest-ads` - Pinterest Ads - * * `postgresql` - Postgresql - * * `reddit-ads` - Reddit Ads - * * `resend` - Resend - * * `s3-compatible` - S3 Compatible - * * `salesforce` - Salesforce - * * `slack` - Slack - * * `slack-posthog-code` - Slack Posthog Code - * * `snapchat` - Snapchat - * * `snowflake` - Snowflake - * * `stripe` - Stripe - * * `tiktok-ads` - Tiktok Ads - * * `twilio` - Twilio - * * `vercel` - Vercel - */ - kind?: IntegrationsGithubAvailableInstallationsListKind - /** - * Number of results to return per page. - */ - limit?: number - /** - * The initial index from which to return the results. - */ - offset?: number -} - -export type IntegrationsGithubAvailableInstallationsListKind = - (typeof IntegrationsGithubAvailableInstallationsListKind)[keyof typeof IntegrationsGithubAvailableInstallationsListKind] - -export const IntegrationsGithubAvailableInstallationsListKind = { - Anthropic: 'anthropic', - Apns: 'apns', - AwsS3: 'aws-s3', - AzureBlob: 'azure-blob', - BingAds: 'bing-ads', - Clickup: 'clickup', - CustomerioApp: 'customerio-app', - CustomerioTrack: 'customerio-track', - CustomerioWebhook: 'customerio-webhook', - Databricks: 'databricks', - Email: 'email', - Firebase: 'firebase', - Github: 'github', - Gitlab: 'gitlab', - GoogleAds: 'google-ads', - GoogleAnalytics: 'google-analytics', - GoogleCloudServiceAccount: 'google-cloud-service-account', - GoogleCloudStorage: 'google-cloud-storage', - GooglePubsub: 'google-pubsub', - GoogleSearchConsole: 'google-search-console', - GoogleSheets: 'google-sheets', - Hubspot: 'hubspot', - Intercom: 'intercom', - Jira: 'jira', - Linear: 'linear', - LinkedinAds: 'linkedin-ads', - MetaAds: 'meta-ads', - Pardot: 'pardot', - PinterestAds: 'pinterest-ads', - Postgresql: 'postgresql', - RedditAds: 'reddit-ads', - Resend: 'resend', - S3Compatible: 's3-compatible', - Salesforce: 'salesforce', - Slack: 'slack', - SlackPosthogCode: 'slack-posthog-code', - Snapchat: 'snapchat', - Snowflake: 'snowflake', - Stripe: 'stripe', - TiktokAds: 'tiktok-ads', - Twilio: 'twilio', - Vercel: 'vercel', -} as const diff --git a/products/integrations/frontend/generated/api.ts b/products/integrations/frontend/generated/api.ts index 95b0daecf5bc..90740401df22 100644 --- a/products/integrations/frontend/generated/api.ts +++ b/products/integrations/frontend/generated/api.ts @@ -9,6 +9,7 @@ import { apiMutator } from '../../../../frontend/src/lib/api-orval-mutator' * OpenAPI spec version: 1.0.0 */ import type { + GitHubAvailableInstallationsResponseApi, GitHubBranchesResponseApi, GitHubLinkExistingRequestApi, GitHubOAuthAuthorizeRequestApi, @@ -21,7 +22,6 @@ import type { IntegrationAccessRequestResponseApi, IntegrationConfigApi, IntegrationsChannelsRetrieveParams, - IntegrationsGithubAvailableInstallationsListParams, IntegrationsGithubBranchesRetrieveParams, IntegrationsGithubReposRetrieveParams, IntegrationsGithubTeamsRetrieveParams, @@ -29,7 +29,6 @@ import type { JiraProjectsResponseApi, LinearTeamsResponseApi, OrganizationIntegrationApi, - PaginatedGitHubAvailableInstallationListApi, PaginatedIntegrationConfigListApi, PaginatedRoleExternalReferenceListApi, PatchedIntegrationConfigApi, @@ -671,23 +670,8 @@ export const integrationsDomainConnectCheckRetrieve = async ( }) } -export const getIntegrationsGithubAvailableInstallationsListUrl = ( - projectId: string, - params?: IntegrationsGithubAvailableInstallationsListParams -) => { - const normalizedParams = new URLSearchParams() - - Object.entries(params || {}).forEach(([key, value]) => { - if (value !== undefined) { - normalizedParams.append(key, value === null ? 'null' : String(value)) - } - }) - - const stringifiedParams = normalizedParams.toString() - - return stringifiedParams.length > 0 - ? `/api/projects/${projectId}/integrations/github/available_installations/?${stringifiedParams}` - : `/api/projects/${projectId}/integrations/github/available_installations/` +export const getIntegrationsGithubAvailableInstallationsRetrieveUrl = (projectId: string) => { + return `/api/projects/${projectId}/integrations/github/available_installations/` } /** @@ -697,13 +681,12 @@ export const getIntegrationsGithubAvailableInstallationsListUrl = ( * installation rather than reinstalling. This backs the picker: when the org has more than * one installation, the client passes the chosen installation_id to github/link_existing. */ -export const integrationsGithubAvailableInstallationsList = async ( +export const integrationsGithubAvailableInstallationsRetrieve = async ( projectId: string, - params?: IntegrationsGithubAvailableInstallationsListParams, options?: RequestInit -): Promise => { - return apiMutator( - getIntegrationsGithubAvailableInstallationsListUrl(projectId, params), +): Promise => { + return apiMutator( + getIntegrationsGithubAvailableInstallationsRetrieveUrl(projectId), { ...options, method: 'GET', diff --git a/products/integrations/mcp/tools.yaml b/products/integrations/mcp/tools.yaml index b14e21141dad..373d6b4be18c 100644 --- a/products/integrations/mcp/tools.yaml +++ b/products/integrations/mcp/tools.yaml @@ -100,8 +100,8 @@ tools: integrations-environment-mapping-partial-update: operation: integrations_environment_mapping_partial_update enabled: false - integrations-github-available-installations-list: - operation: integrations_github_available_installations_list + integrations-github-available-installations-retrieve: + operation: integrations_github_available_installations_retrieve enabled: false integrations-github-branches-retrieve: operation: integrations_github_branches_retrieve diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 75dbd3f65a4d..129970a95756 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -32497,6 +32497,11 @@ export namespace Schemas { source_team_id: number; } + export interface GitHubAvailableInstallationsResponse { + /** Distinct GitHub installations in the organization available to link to this project. */ + installations: GitHubAvailableInstallation[]; + } + export interface GitHubBranchesResponse { /** List of branch names */ branches: string[]; @@ -42743,15 +42748,6 @@ export namespace Schemas { results: FolderInstructionsVersion[]; } - export interface PaginatedGitHubAvailableInstallationList { - count: number; - /** @nullable */ - next?: string | null; - /** @nullable */ - previous?: string | null; - results: GitHubAvailableInstallation[]; - } - export interface PaginatedGroupUsageMetricList { count: number; /** @nullable */ @@ -78571,110 +78567,6 @@ export namespace Schemas { search?: string; }; - export type IntegrationsGithubAvailableInstallationsListParams = { - /** - * * `anthropic` - Anthropic - * * `apns` - Apple Push - * * `aws-s3` - Aws S3 - * * `azure-blob` - Azure Blob - * * `bing-ads` - Bing Ads - * * `clickup` - Clickup - * * `customerio-app` - Customerio App - * * `customerio-track` - Customerio Track - * * `customerio-webhook` - Customerio Webhook - * * `databricks` - Databricks - * * `email` - Email - * * `firebase` - Firebase - * * `github` - Github - * * `gitlab` - Gitlab - * * `google-ads` - Google Ads - * * `google-analytics` - Google Analytics - * * `google-cloud-service-account` - Google Cloud Service Account - * * `google-cloud-storage` - Google Cloud Storage - * * `google-pubsub` - Google Pubsub - * * `google-search-console` - Google Search Console - * * `google-sheets` - Google Sheets - * * `hubspot` - Hubspot - * * `intercom` - Intercom - * * `jira` - Jira - * * `linear` - Linear - * * `linkedin-ads` - Linkedin Ads - * * `meta-ads` - Meta Ads - * * `pardot` - Pardot - * * `pinterest-ads` - Pinterest Ads - * * `postgresql` - Postgresql - * * `reddit-ads` - Reddit Ads - * * `resend` - Resend - * * `s3-compatible` - S3 Compatible - * * `salesforce` - Salesforce - * * `slack` - Slack - * * `slack-posthog-code` - Slack Posthog Code - * * `snapchat` - Snapchat - * * `snowflake` - Snowflake - * * `stripe` - Stripe - * * `tiktok-ads` - Tiktok Ads - * * `twilio` - Twilio - * * `vercel` - Vercel - */ - kind?: IntegrationsGithubAvailableInstallationsListKind; - /** - * Number of results to return per page. - */ - limit?: number; - /** - * The initial index from which to return the results. - */ - offset?: number; - }; - - export type IntegrationsGithubAvailableInstallationsListKind = typeof IntegrationsGithubAvailableInstallationsListKind[keyof typeof IntegrationsGithubAvailableInstallationsListKind]; - - - export const IntegrationsGithubAvailableInstallationsListKind = { - Anthropic: 'anthropic', - Apns: 'apns', - AwsS3: 'aws-s3', - AzureBlob: 'azure-blob', - BingAds: 'bing-ads', - Clickup: 'clickup', - CustomerioApp: 'customerio-app', - CustomerioTrack: 'customerio-track', - CustomerioWebhook: 'customerio-webhook', - Databricks: 'databricks', - Email: 'email', - Firebase: 'firebase', - Github: 'github', - Gitlab: 'gitlab', - GoogleAds: 'google-ads', - GoogleAnalytics: 'google-analytics', - GoogleCloudServiceAccount: 'google-cloud-service-account', - GoogleCloudStorage: 'google-cloud-storage', - GooglePubsub: 'google-pubsub', - GoogleSearchConsole: 'google-search-console', - GoogleSheets: 'google-sheets', - Hubspot: 'hubspot', - Intercom: 'intercom', - Jira: 'jira', - Linear: 'linear', - LinkedinAds: 'linkedin-ads', - MetaAds: 'meta-ads', - Pardot: 'pardot', - PinterestAds: 'pinterest-ads', - Postgresql: 'postgresql', - RedditAds: 'reddit-ads', - Resend: 'resend', - S3Compatible: 's3-compatible', - Salesforce: 'salesforce', - Slack: 'slack', - SlackPosthogCode: 'slack-posthog-code', - Snapchat: 'snapchat', - Snowflake: 'snowflake', - Stripe: 'stripe', - TiktokAds: 'tiktok-ads', - Twilio: 'twilio', - Vercel: 'vercel', - } as const; - export type JsSnippetResolveRetrieve200 = { [key: string]: unknown }; export type JsSnippetVersionRetrieve200 = { [key: string]: unknown }; From 0114b0d13d53289689ce42eaabea75a165992100 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Fri, 31 Jul 2026 11:46:35 +0200 Subject: [PATCH 09/13] fix(integrations): resolve linkable GitHub installations within the caller's access The source-project access check ran after resolving the source across the whole org, so the picker and the link path disagreed: - an installation shared by a private project and an accessible one resolved to the private project's lower-id row and was rejected, despite being offered - auto-resolve counted installations the caller can't see, so the one-click button dead-ended on the ambiguous-installation error the picker exists to avoid Filter candidates by the accessible team set during resolution instead. Also defer the repository cache when listing installations, and call the generated client instead of the legacy api.ts helper. --- frontend/src/lib/api.ts | 8 -- .../src/lib/integrations/integrationsLogic.ts | 6 +- posthog/api/github_callback/team_services.py | 33 ++++---- posthog/api/test/test_integration.py | 81 ++++++++++++++++--- 4 files changed, 90 insertions(+), 38 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 41f1343b5e74..5fb6363e57cf 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -233,7 +233,6 @@ import type { SymbolSetOrder } from 'products/error_tracking/frontend/scenes/Err import type { ErrorTrackingRecommendation } from 'products/error_tracking/frontend/scenes/ErrorTrackingScene/tabs/recommendations/types' import type { CopyFlagsResponseApi } from 'products/feature_flags/frontend/generated/api.schemas' import type { - GitHubAvailableInstallationApi, GitHubBranchesResponseApi, GitHubReposResponseApi, } from 'products/integrations/frontend/generated/api.schemas' @@ -6315,13 +6314,6 @@ const api = { ): Promise { return await new ApiRequest().integrations(teamId).withAction('github/link_existing').create({ data }) }, - async githubAvailableInstallations(teamId?: TeamType['id']): Promise { - const response = await new ApiRequest() - .integrations(teamId) - .withAction('github/available_installations') - .get() - return response.installations - }, async githubOAuthAuthorize( data: { installation_id: string | number diff --git a/frontend/src/lib/integrations/integrationsLogic.ts b/frontend/src/lib/integrations/integrationsLogic.ts index d09f7b8b4e70..e9fa460588b5 100644 --- a/frontend/src/lib/integrations/integrationsLogic.ts +++ b/frontend/src/lib/integrations/integrationsLogic.ts @@ -17,6 +17,7 @@ import { urls } from 'scenes/urls' import { EmailIntegrationDomainGroupedType, IntegrationKind, IntegrationType } from '~/types' import { + integrationsGithubAvailableInstallationsRetrieve, integrationsGithubReposRetrieve, integrationsRequestAccessCreate, } from 'products/integrations/frontend/generated/api' @@ -856,7 +857,10 @@ export const integrationsLogic = kea([ null as GitHubAvailableInstallationApi[] | null, { loadGithubAvailableInstallations: async () => { - return await api.integrations.githubAvailableInstallations() + const response = await integrationsGithubAvailableInstallationsRetrieve( + String(values.currentProjectId) + ) + return response.installations }, }, ], diff --git a/posthog/api/github_callback/team_services.py b/posthog/api/github_callback/team_services.py index 57f68debfff0..b263fb0079e7 100644 --- a/posthog/api/github_callback/team_services.py +++ b/posthog/api/github_callback/team_services.py @@ -32,6 +32,7 @@ GitHubIntegration, GitHubUserAuthorization, Integration, + defer_repository_cache_fields, invalidate_github_repository_caches_for_installation, ) from posthog.models.organization import Organization @@ -420,13 +421,18 @@ def link_existing_team_github_integration( if installation_id_param and not is_valid_github_installation_id(installation_id_param): raise ValidationError("Invalid installation_id") + # Reusing a source project's GitHub access requires access to that project; target-team admin isn't + # enough. Filter the candidates rather than checking the winner, so this stays in step with what + # `list_org_github_installations` offers. + accessible_team_ids = _accessible_org_team_ids(user, organization) + if source_team_id: try: source_team_id_int = int(source_team_id) except (TypeError, ValueError): raise ValidationError("source_team_id must be an integer") - if not organization.teams.filter(id=source_team_id_int).exists(): + if source_team_id_int not in accessible_team_ids: raise ValidationError("Source team not found in your organization") qs = Integration.objects.filter(team_id=source_team_id_int, kind="github") @@ -440,6 +446,7 @@ def link_existing_team_github_integration( existing = ( Integration.objects.filter( team__organization_id=organization.id, + team_id__in=accessible_team_ids, kind="github", ) .for_github_installation_id(str(installation_id_param)) @@ -457,7 +464,9 @@ def link_existing_team_github_integration( # one-click "Link existing installation" UI, where a second project reuses the org's single # install without the caller having to know a sibling team id or the installation id. org_github = ( - Integration.objects.filter(team__organization_id=organization.id, kind="github") + Integration.objects.filter( + team__organization_id=organization.id, team_id__in=accessible_team_ids, kind="github" + ) .exclude(team_id=team_id) .order_by("id") ) @@ -482,16 +491,6 @@ def link_existing_team_github_integration( code=GITHUB_LINK_EXISTING_ERROR_ORPHAN_INSTALLATION, ) - # The source project is the one whose GitHub access we're reusing, so the requester must be able - # to access it — target-team admin alone is not enough. Without this, an org member who admins the - # target project but is locked out of a private sibling could pull that sibling's installation - # (and its repositories) in by supplying its source_team_id/installation_id. - if source.team_id not in _accessible_org_team_ids(user, organization): - raise ValidationError( - "No team in your organization has this GitHub installation linked", - code=GITHUB_LINK_EXISTING_ERROR_ORPHAN_INSTALLATION, - ) - installation_id = (source.config or {}).get("installation_id") if not installation_id: raise ValidationError("Source integration is missing installation_id") @@ -529,10 +528,12 @@ def list_org_github_installations( access boundary enforced in ``link_existing_team_github_integration`` so the picker never surfaces an installation the user couldn't actually link. """ - org_github = Integration.objects.filter( - team__organization_id=organization.id, - team_id__in=_accessible_org_team_ids(user, organization), - kind="github", + org_github = defer_repository_cache_fields( + Integration.objects.filter( + team__organization_id=organization.id, + team_id__in=_accessible_org_team_ids(user, organization), + kind="github", + ) ) if exclude_team_id is not None: org_github = org_github.exclude(team_id=exclude_team_id) diff --git a/posthog/api/test/test_integration.py b/posthog/api/test/test_integration.py index 973d7577f81f..1f12f569b28d 100644 --- a/posthog/api/test/test_integration.py +++ b/posthog/api/test/test_integration.py @@ -3200,28 +3200,35 @@ def test_list_org_github_installations_dedupes_and_excludes_target_team(self): assert installations[0]["account_type"] == "Organization" assert installations[0]["source_team_id"] == first.pk - def test_link_existing_rejects_installation_from_inaccessible_source_project(self): - # A user who admins the target project but is locked out of a private sibling must not be able - # to discover or reuse that sibling's installation — target-team admin is not access to the - # source project. Without the source-team access boundary this both leaks the installation in - # the picker and links its repositories into the target. + def _org_member_with_access_control(self) -> User: self.organization.available_product_features = [ {"key": AvailableFeature.ACCESS_CONTROL, "name": AvailableFeature.ACCESS_CONTROL}, ] self.organization.save() - member = User.objects.create_and_join( + return User.objects.create_and_join( self.organization, "outsider@posthog.com", "test", level=OrganizationMembership.Level.MEMBER ) - private = Team.objects.create(organization=self.organization, name="Private Project") - AccessControl.objects.create(team=private, resource="project", access_level="none") - Integration.objects.create( - team=private, + + def _sibling_github_integration(self, name: str, installation_id: str, private: bool = False) -> Integration: + team = Team.objects.create(organization=self.organization, name=name) + if private: + AccessControl.objects.create(team=team, resource="project", access_level="none") + return Integration.objects.create( + team=team, kind="github", - integration_id="777", - config={"installation_id": "777", "account": {"name": "secret", "type": "Organization"}}, - sensitive_config={"access_token": "ghs_private"}, + integration_id=installation_id, + config={"installation_id": installation_id, "account": {"name": name, "type": "Organization"}}, + sensitive_config={"access_token": f"ghs_{installation_id}"}, ) + def test_link_existing_rejects_installation_from_inaccessible_source_project(self): + # A user who admins the target project but is locked out of a private sibling must not be able + # to discover or reuse that sibling's installation — target-team admin is not access to the + # source project. Without the source-team access boundary this both leaks the installation in + # the picker and links its repositories into the target. + member = self._org_member_with_access_control() + self._sibling_github_integration("Private Project", "777", private=True) + installations = list_org_github_installations( user=member, organization=self.organization, exclude_team_id=self.team.pk ) @@ -3238,6 +3245,54 @@ def test_link_existing_rejects_installation_from_inaccessible_source_project(sel codes = exc_info.value.get_codes() assert isinstance(codes, list) and GITHUB_LINK_EXISTING_ERROR_ORPHAN_INSTALLATION in codes + @patch("posthog.models.integration.GitHubIntegration.integration_from_installation_id") + def test_link_existing_links_installation_also_held_by_an_inaccessible_project(self, mock_from_install): + # One installation shared by a private project and an accessible one. Resolving the source + # across the whole org and only then checking access would settle on the private project's + # lower-id row and reject an installation the picker just offered. + member = self._org_member_with_access_control() + self._sibling_github_integration("Private Project", "111", private=True) + self._sibling_github_integration("Shared Project", "111") + mock_from_install.side_effect = lambda *args, **kwargs: self._team_github_integration(installation_id="111") + + installations = list_org_github_installations( + user=member, organization=self.organization, exclude_team_id=self.team.pk + ) + assert [installation["installation_id"] for installation in installations] == ["111"] + + link_existing_team_github_integration( + user=member, + organization=self.organization, + team_id=self.team.pk, + source_team_id=None, + installation_id_param="111", + ) + assert mock_from_install.call_args.args[0] == "111" + + @patch("posthog.models.integration.GitHubIntegration.integration_from_installation_id") + def test_auto_resolve_ignores_installations_the_user_cannot_access(self, mock_from_install): + # The picker offers exactly one installation, so the UI sends the one-click empty payload. + # Counting installations the caller can't see would call that ambiguous and dead-end the very + # flow the picker exists to unblock. + member = self._org_member_with_access_control() + self._sibling_github_integration("Private Project", "111", private=True) + self._sibling_github_integration("Shared Project", "222") + mock_from_install.side_effect = lambda *args, **kwargs: self._team_github_integration(installation_id="222") + + installations = list_org_github_installations( + user=member, organization=self.organization, exclude_team_id=self.team.pk + ) + assert [installation["installation_id"] for installation in installations] == ["222"] + + link_existing_team_github_integration( + user=member, + organization=self.organization, + team_id=self.team.pk, + source_team_id=None, + installation_id_param=None, + ) + assert mock_from_install.call_args.args[0] == "222" + def test_cross_user_state_rejected_on_unified_callback(self, client: HttpClient): # State tokens are bound to a user via the pending-pointer cache key. # Another admin in the same team must not be able to finish a callback From d23104b8e5f2c31931f448969d4d488ff9befbdc Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Fri, 31 Jul 2026 12:30:21 +0200 Subject: [PATCH 10/13] fix(integrations): name the GitHub button after where it goes 'Connect organization' was wrong three ways: nothing connects in PostHog when you click it, you can install on a personal account rather than an org, and where you land is GitHub's call. A GitHub App installs at most once per account, so GitHub offers install where it's missing and configure where it isn't, which is why clicking it with one installation just opens that installation's settings. Name the destination instead, and always explain what's on the other side. The supporting line previously only rendered when an existing installation could be linked, so the common case had no explanation at all. --- .../scenes/integrations/components/Integrations.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/scenes/integrations/components/Integrations.tsx b/frontend/src/scenes/integrations/components/Integrations.tsx index 936771a9c523..1df26d470e8b 100644 --- a/frontend/src/scenes/integrations/components/Integrations.tsx +++ b/frontend/src/scenes/integrations/components/Integrations.tsx @@ -52,8 +52,12 @@ export function GithubIntegration({ next }: { next?: string }): JSX.Element {
+ {/* This leaves PostHog entirely. GitHub decides what it shows, since an app + installs at most once per account: install where it's missing, configure + where it's already there. So the label names the destination, not an + outcome we can promise. */} - Connect organization + Manage on GitHub {canLinkExisting && ( )}
+

+ Install the PostHog app on a GitHub account, or change which repositories an existing installation + can see. +

{canLinkExisting && (

{multipleInstallations ? 'Choose an existing GitHub installation to connect to this project.' - : 'Already installed the PostHog GitHub App for another project in this organization? A GitHub App installs once per organization, so use "Link existing installation" to connect it here instead of reinstalling.'} + : 'A GitHub App installs once per organization. Link the installation you already have instead of reinstalling.'}

)}
From 058a93d868ca9b5f65755c28ef8f7def67cfc13a Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Fri, 31 Jul 2026 12:44:20 +0200 Subject: [PATCH 11/13] fix(integrations): use LemonMenu for the installation picker Quill was built for MCP apps and desktop, not the main app, and its components read as out of place next to the surrounding Lemon UI. LemonMenu is what the rest of this scene uses. The AGENTS.md line pointing new menus at quill is stale and is being corrected separately. --- .../integrations/components/Integrations.tsx | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/frontend/src/scenes/integrations/components/Integrations.tsx b/frontend/src/scenes/integrations/components/Integrations.tsx index 1df26d470e8b..9bbf42d6e1f8 100644 --- a/frontend/src/scenes/integrations/components/Integrations.tsx +++ b/frontend/src/scenes/integrations/components/Integrations.tsx @@ -3,11 +3,11 @@ import { PropsWithChildren, useMemo, useState } from 'react' import { IconChevronDown } from '@posthog/icons' import { LemonButton } from '@posthog/lemon-ui' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@posthog/quill' import api from 'lib/api' import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { IntegrationView } from 'lib/integrations/IntegrationView' +import { LemonMenu } from 'lib/lemon-ui/LemonMenu' import { GitLabSetupModal } from 'scenes/integrations/gitlab/GitLabSetupModal' import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' @@ -105,24 +105,18 @@ export function GitHubInstallationLink({ } return ( - - } />} - > + ({ + key: installation.installation_id, + label: installation.account_name ?? `Installation ${installation.installation_id}`, + disabledReason: loading ? 'Linking an installation' : undefined, + onClick: () => onLink(installation.installation_id), + }))} + > + }> Link existing installation - - - {installations.map((installation) => ( - onLink(installation.installation_id)} - > - {installation.account_name ?? `Installation ${installation.installation_id}`} - - ))} - - + + ) } From 4d3e575d0cab169ccdf16d1b456000f751f1a3b7 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Fri, 31 Jul 2026 13:06:33 +0200 Subject: [PATCH 12/13] fix(integrations): keep 'Connect organization' until GitHub is linked Naming the destination only earns its keep once something is connected. With nothing linked to the project, connecting is exactly what the button does, and the original label says so. Gate the supporting line on the same condition: it explains what 'Manage on GitHub' leads to, which is only a question once the project is connected. That also leaves the unconnected state rendering exactly as it did when the visual baselines were last taken. --- .../integrations/components/Integrations.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/frontend/src/scenes/integrations/components/Integrations.tsx b/frontend/src/scenes/integrations/components/Integrations.tsx index 9bbf42d6e1f8..65985e7de13e 100644 --- a/frontend/src/scenes/integrations/components/Integrations.tsx +++ b/frontend/src/scenes/integrations/components/Integrations.tsx @@ -45,19 +45,20 @@ export function GithubIntegration({ next }: { next?: string }): JSX.Element { }) const installations = githubAvailableInstallations ?? [] - const canLinkExisting = githubIntegrations.length === 0 && installations.length > 0 + const isConnected = githubIntegrations.length > 0 + const canLinkExisting = !isConnected && installations.length > 0 const multipleInstallations = installations.length > 1 return (
- {/* This leaves PostHog entirely. GitHub decides what it shows, since an app - installs at most once per account: install where it's missing, configure - where it's already there. So the label names the destination, not an - outcome we can promise. */} + {/* This leaves PostHog entirely, and a GitHub App installs at most once per + account, so GitHub offers install where it's missing and configure where it + isn't. Connecting is only a promise we can keep while this project has + nothing linked; past that the honest label is the destination. */} - Manage on GitHub + {isConnected ? 'Manage on GitHub' : 'Connect organization'} {canLinkExisting && ( )}
-

- Install the PostHog app on a GitHub account, or change which repositories an existing installation - can see. -

+ {isConnected && ( +

+ Install the PostHog app on another GitHub account, or change which repositories this + installation can see. +

+ )} {canLinkExisting && (

{multipleInstallations From 7a88da886c56ec9011adb865dbd353f76ce60438 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Fri, 31 Jul 2026 14:49:42 +0200 Subject: [PATCH 13/13] fix(integrations): fetch GitHub installations only where the picker renders Also defer the repository cache blob in the auto-resolve branch, which only reads installation ids. --- frontend/src/lib/integrations/integrationsLogic.ts | 10 ++-------- .../scenes/integrations/components/Integrations.tsx | 9 ++++++++- posthog/api/github_callback/team_services.py | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/integrations/integrationsLogic.ts b/frontend/src/lib/integrations/integrationsLogic.ts index e9fa460588b5..94a357abf873 100644 --- a/frontend/src/lib/integrations/integrationsLogic.ts +++ b/frontend/src/lib/integrations/integrationsLogic.ts @@ -856,6 +856,8 @@ export const integrationsLogic = kea([ githubAvailableInstallations: [ null as GitHubAvailableInstallationApi[] | null, { + // The org's other GitHub installations, so the UI can offer a picker when there's + // more than one, rather than failing the auto-resolve link as ambiguous. loadGithubAvailableInstallations: async () => { const response = await integrationsGithubAvailableInstallationsRetrieve( String(values.currentProjectId) @@ -884,14 +886,6 @@ export const integrationsLogic = kea([ ], })), listeners(({ actions, values }) => ({ - loadIntegrationsSuccess: ({ integrations }) => { - // "Link existing installation" only applies when this project has no GitHub integration - // yet. Fetch the org's installations so the UI can offer a picker when more than one - // exists, rather than failing the auto-resolve link with an ambiguous-installation error. - if (!integrations?.some((integration) => integration.kind === 'github')) { - actions.loadGithubAvailableInstallations() - } - }, loadGitHubRepositories: ({ integrationId }) => { actions.loadGitHubRepositoriesPage(integrationId, 0) }, diff --git a/frontend/src/scenes/integrations/components/Integrations.tsx b/frontend/src/scenes/integrations/components/Integrations.tsx index 65985e7de13e..4fb03ca79900 100644 --- a/frontend/src/scenes/integrations/components/Integrations.tsx +++ b/frontend/src/scenes/integrations/components/Integrations.tsx @@ -5,6 +5,7 @@ import { IconChevronDown } from '@posthog/icons' import { LemonButton } from '@posthog/lemon-ui' import api from 'lib/api' +import { useOnMountEffect } from 'lib/hooks/useOnMountEffect' import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { IntegrationView } from 'lib/integrations/IntegrationView' import { LemonMenu } from 'lib/lemon-ui/LemonMenu' @@ -35,9 +36,15 @@ export function LinearIntegration({ next }: { next?: string }): JSX.Element { export function GithubIntegration({ next }: { next?: string }): JSX.Element { const { currentTeam } = useValues(teamLogic) const { linkedGithubInstallationLoading, githubAvailableInstallations } = useValues(integrationsLogic) - const { linkExistingGithubInstallation } = useActions(integrationsLogic) + const { linkExistingGithubInstallation, loadGithubAvailableInstallations } = useActions(integrationsLogic) const githubIntegrations = useIntegrations('github') + // integrationsLogic is a singleton mounted from dozens of unrelated surfaces, so this fetch + // hangs off the GitHub setup UI instead of the shared integrations load. + useOnMountEffect(() => { + loadGithubAvailableInstallations() + }) + const settingsPath = next ?? urls.settings('environment-integrations') const authorizationUrl = api.integrations.authorizeUrl({ next: currentTeam?.id ? urls.project(currentTeam.id, settingsPath) : settingsPath, diff --git a/posthog/api/github_callback/team_services.py b/posthog/api/github_callback/team_services.py index b263fb0079e7..d3a7295a28ac 100644 --- a/posthog/api/github_callback/team_services.py +++ b/posthog/api/github_callback/team_services.py @@ -472,7 +472,7 @@ def link_existing_team_github_integration( ) distinct_installation_ids = { str(config_installation_id) - for integration in org_github + for integration in defer_repository_cache_fields(org_github) if (config_installation_id := (integration.config or {}).get("installation_id")) } if not distinct_installation_ids: