diff --git a/frontend/snapshots.yml b/frontend/snapshots.yml index 3edcd72c8f9c..41751b360dd4 100644 --- a/frontend/snapshots.yml +++ b/frontend/snapshots.yml @@ -6987,9 +6987,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: @@ -7003,9 +7003,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: @@ -7023,9 +7023,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: @@ -7087,17 +7087,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: diff --git a/frontend/src/lib/integrations/integrationsLogic.ts b/frontend/src/lib/integrations/integrationsLogic.ts index f8532040b010..94a357abf873 100644 --- a/frontend/src/lib/integrations/integrationsLogic.ts +++ b/frontend/src/lib/integrations/integrationsLogic.ts @@ -17,10 +17,15 @@ import { urls } from 'scenes/urls' import { EmailIntegrationDomainGroupedType, IntegrationKind, IntegrationType } from '~/types' import { + integrationsGithubAvailableInstallationsRetrieve, 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,6 +91,8 @@ export interface integrationsLogicValues { | 'vercel' )[] ) => IntegrationType[] + githubAvailableInstallations: GitHubAvailableInstallationApi[] | null + githubAvailableInstallationsLoading: boolean githubIntegrations: IntegrationType[] githubRepositories: Record githubRepositoriesLoading: boolean @@ -162,7 +169,7 @@ export interface integrationsLogicActions { | 'vercel' searchParams: any } - linkExistingGithubInstallation: () => any + linkExistingGithubInstallation: (installationId?: string) => string linkExistingGithubInstallationFailure: ( error: string, errorObject?: any @@ -172,10 +179,10 @@ export interface integrationsLogicActions { } linkExistingGithubInstallationSuccess: ( linkedGithubInstallation: IntegrationType, - payload?: any + payload?: string ) => { linkedGithubInstallation: IntegrationType - payload?: any + payload?: string } loadGitHubRepositories: (integrationId: number) => { integrationId: number @@ -199,6 +206,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, @@ -814,9 +836,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 +853,19 @@ 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) + ) + return response.installations + }, + }, + ], accessRequest: [ null as IntegrationKind | null, { 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 de8000c86df6..4fb03ca79900 100644 --- a/frontend/src/scenes/integrations/components/Integrations.tsx +++ b/frontend/src/scenes/integrations/components/Integrations.tsx @@ -1,17 +1,22 @@ import { useActions, useValues } from 'kea' import { PropsWithChildren, useMemo, useState } from 'react' +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' import { GitLabSetupModal } from 'scenes/integrations/gitlab/GitLabSetupModal' import { teamLogic } from 'scenes/teamLogic' 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 ( @@ -30,38 +35,57 @@ 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 { linkExistingGithubInstallation } = useActions(integrationsLogic) + const { linkedGithubInstallationLoading, githubAvailableInstallations } = useValues(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, kind: 'github', }) + const installations = githubAvailableInstallations ?? [] + const isConnected = githubIntegrations.length > 0 + const canLinkExisting = !isConnected && installations.length > 0 + const multipleInstallations = installations.length > 1 + return (
-
+
+ {/* 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. */} - Connect organization + {isConnected ? 'Manage on GitHub' : 'Connect organization'} - {githubIntegrations.length === 0 && ( - linkExistingGithubInstallation()} - > - Link existing installation - + onLink={linkExistingGithubInstallation} + /> )}
- {githubIntegrations.length === 0 && ( + {isConnected && (

- 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. + Install the PostHog app on another GitHub account, or change which repositories this + installation can see. +

+ )} + {canLinkExisting && ( +

+ {multipleInstallations + ? 'Choose an existing GitHub installation to connect to this project.' + : 'A GitHub App installs once per organization. Link the installation you already have instead of reinstalling.'}

)}
@@ -69,6 +93,43 @@ 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 ( + ({ + 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 + + + ) +} + export function JiraIntegration({ next }: { next?: string }): JSX.Element { return } diff --git a/posthog/api/github_callback/team_services.py b/posthog/api/github_callback/team_services.py index 8b269d4223bb..d3a7295a28ac 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 @@ -399,6 +400,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, @@ -410,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") @@ -430,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)) @@ -447,13 +464,15 @@ 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") ) 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: @@ -491,6 +510,54 @@ def link_existing_team_github_integration( return instance +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 ``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 = 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) + 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 c69136a33e37..a88d860e271c 100644 --- a/posthog/api/integration.py +++ b/posthog/api/integration.py @@ -27,6 +27,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, @@ -906,6 +907,30 @@ 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 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, @@ -960,6 +985,7 @@ class IntegrationViewSet( "github_repos", "github_branches", "github_teams", + "github_available_installations", "jira_projects", "linear_teams", "anthropic_managed_agents", @@ -1582,6 +1608,22 @@ def github_prepare_callback(self, request: Request, *args: Any, **kwargs: Any) - ) return Response(status=204) + @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. + + 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( + user=cast(User, request.user), + organization=self.organization, + exclude_team_id=self.team_id, + ) + return Response({"installations": 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 f53ebe211a55..d945ab6b9bf2 100644 --- a/posthog/api/test/test_integration.py +++ b/posthog/api/test/test_integration.py @@ -21,12 +21,15 @@ 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, + list_org_github_installations, ) 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, @@ -54,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) @@ -3137,6 +3142,157 @@ 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( + 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 _org_member_with_access_control(self) -> User: + self.organization.available_product_features = [ + {"key": AvailableFeature.ACCESS_CONTROL, "name": AvailableFeature.ACCESS_CONTROL}, + ] + self.organization.save() + return User.objects.create_and_join( + self.organization, "outsider@posthog.com", "test", level=OrganizationMembership.Level.MEMBER + ) + + 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=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 + ) + 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 + + @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 diff --git a/products/integrations/frontend/generated/api.schemas.ts b/products/integrations/frontend/generated/api.schemas.ts index e6b621242758..0360bc918fcd 100644 --- a/products/integrations/frontend/generated/api.schemas.ts +++ b/products/integrations/frontend/generated/api.schemas.ts @@ -389,6 +389,28 @@ 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 GitHubAvailableInstallationsResponseApi { + /** Distinct GitHub installations in the organization available to link to this project. */ + installations: GitHubAvailableInstallationApi[] +} + export interface GitHubLinkExistingRequestApi { /** * Sibling team in the same organization whose GitHub installation should be reused. diff --git a/products/integrations/frontend/generated/api.ts b/products/integrations/frontend/generated/api.ts index 4518df9996b7..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, @@ -669,6 +670,30 @@ export const integrationsDomainConnectCheckRetrieve = async ( }) } +export const getIntegrationsGithubAvailableInstallationsRetrieveUrl = (projectId: string) => { + return `/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 integrationsGithubAvailableInstallationsRetrieve = async ( + projectId: string, + options?: RequestInit +): Promise => { + return apiMutator( + getIntegrationsGithubAvailableInstallationsRetrieveUrl(projectId), + { + ...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..373d6b4be18c 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-retrieve: + operation: integrations_github_available_installations_retrieve + 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 718c9cdbe23a..45cfc46932ca 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -32912,6 +32912,28 @@ 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 GitHubAvailableInstallationsResponse { + /** Distinct GitHub installations in the organization available to link to this project. */ + installations: GitHubAvailableInstallation[]; + } + export interface GitHubBranchesResponse { /** List of branch names */ branches: string[];