diff --git a/static/app/views/integrationOrganizationLink/gitHubInstallationCallout.spec.tsx b/static/app/views/integrationOrganizationLink/gitHubInstallationCallout.spec.tsx new file mode 100644 index 000000000000..a1cb55c10bda --- /dev/null +++ b/static/app/views/integrationOrganizationLink/gitHubInstallationCallout.spec.tsx @@ -0,0 +1,58 @@ +import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; + +import * as indicators from 'sentry/actionCreators/indicator'; + +import {GitHubInstallationCallout} from './gitHubInstallationCallout'; + +const INSTALLATION_ID = '123'; +const INSTALLATION_URL = `/extensions/github/installation/${INSTALLATION_ID}/`; + +describe('GitHubInstallationCallout', () => { + beforeEach(() => { + MockApiClient.clearMockResponses(); + }); + + it('renders the info callout when the installation lookup succeeds', async () => { + MockApiClient.addMockResponse({ + url: INSTALLATION_URL, + body: { + account: {login: 'acme-corp', type: 'Organization'}, + sender: {id: 42, login: 'octocat'}, + }, + }); + + render(); + + expect(await screen.findByText(/has installed GitHub app to/)).toBeInTheDocument(); + expect(screen.getByRole('link', {name: 'octocat'})).toHaveAttribute( + 'href', + 'https://github.com/octocat' + ); + expect(screen.getByRole('link', {name: 'acme-corp'})).toHaveAttribute( + 'href', + 'https://github.com/acme-corp' + ); + expect(screen.getByText('Organization')).toBeInTheDocument(); + }); + + it('renders the warning callout and surfaces an error toast when the lookup fails', async () => { + const errorSpy = jest.spyOn(indicators, 'addErrorMessage'); + MockApiClient.addMockResponse({ + url: INSTALLATION_URL, + statusCode: 500, + }); + + render(); + + expect( + await screen.findByText( + 'We could not verify the authenticity of the installation request. We recommend restarting the installation process.' + ) + ).toBeInTheDocument(); + await waitFor(() => { + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to retrieve GitHub installation details' + ); + }); + }); +}); diff --git a/static/app/views/integrationOrganizationLink/gitHubInstallationCallout.tsx b/static/app/views/integrationOrganizationLink/gitHubInstallationCallout.tsx new file mode 100644 index 000000000000..2f4e7d79b871 --- /dev/null +++ b/static/app/views/integrationOrganizationLink/gitHubInstallationCallout.tsx @@ -0,0 +1,126 @@ +import {useEffect} from 'react'; +import {queryOptions, useQuery} from '@tanstack/react-query'; + +import {Alert} from '@sentry/scraps/alert'; +import {ExternalLink} from '@sentry/scraps/link'; + +import {addErrorMessage} from 'sentry/actionCreators/indicator'; +import {Client} from 'sentry/api'; +import {t, tct} from 'sentry/locale'; +import type {ApiResponse} from 'sentry/utils/api/apiFetch'; +import {selectJson} from 'sentry/utils/api/apiOptions'; + +interface GitHubIntegrationInstallation { + account: { + login: string; + type: string; + }; + sender: { + id: number; + login: string; + }; +} + +// XXX: The GitHub installation info endpoint is the odd one out. Unlike the +// rest of the JSON endpoints the frontend consumes, it lives at +// `/extensions/github/installation/:id/` rather than under `/api/0/`. It was +// registered alongside the GitHub webhook + OAuth callback URLs in +// `src/sentry/integrations/github/urls.py` (which all `include()` under the +// `/extensions/github/` namespace) instead of being added to +// `src/sentry/api/urls.py` next to the rest of the integration endpoints. +// +// Because it's outside `/api/0/`, the standard `apiOptions` factory — which +// resolves paths against the `/api/0/` base URL — can't reach it. So we hand- +// roll a `queryOptions` factory that mirrors what `apiOptions` produces: +// +// - an unprefixed `Client({baseUrl: ''})` so the absolute path is sent as-is, +// - a `queryFn` that returns the `ApiResponse` `{json, headers}` shape so +// the React-Query cache stores the same value our other queries do, +// - `select: selectJson` so consumers see the unwrapped JSON. +const NO_PREFIX_API_CLIENT = new Client({baseUrl: ''}); + +function gitHubInstallationOptions(installationId: string) { + return queryOptions({ + queryKey: ['github-installation', installationId] as const, + queryFn: async (): Promise> => { + const [json, , response] = await NO_PREFIX_API_CLIENT.requestPromise( + `/extensions/github/installation/${installationId}/`, + {includeAllArgs: true} + ); + return { + json: json as GitHubIntegrationInstallation, + headers: { + Link: response?.getResponseHeader('Link') ?? undefined, + }, + }; + }, + staleTime: Infinity, + select: selectJson, + }); +} + +interface Props { + installationId: string; +} + +/** + * Renders the "X has installed the Sentry GitHub app to Y" callout shown when + * the user lands on the org-link page from a GitHub-initiated install (i.e. + * the user installed the Sentry app from GitHub's side rather than from the + * Sentry integrations directory). Fetches the installation's sender + target + * account from the backend so the user can confirm the install came from them. + * + * If the lookup fails we render a warning instead — the install can still + * proceed but the sender couldn't be verified. + */ +export function GitHubInstallationCallout({installationId}: Props) { + const {data, error, isPending} = useQuery(gitHubInstallationOptions(installationId)); + + useEffect(() => { + if (error) { + addErrorMessage(t('Failed to retrieve GitHub installation details')); + } + }, [error]); + + if (isPending) { + return null; + } + + if (!data) { + return ( + + + {t( + 'We could not verify the authenticity of the installation request. We recommend restarting the installation process.' + )} + + + ); + } + + const senderUrl = `https://github.com/${data.sender.login}`; + const targetUrl = `https://github.com/${data.account.login}`; + + return ( + + + {tct( + 'GitHub user [senderLogin] has installed GitHub app to [accountType] [accountLogin]. Proceed if you want to attach this installation to your Sentry account.', + { + accountType: {data.account.type}, + accountLogin: ( + + {data.account.login} + + ), + senderLogin: ( + + {data.sender.login} + + ), + } + )} + + + ); +} diff --git a/static/app/views/integrationOrganizationLink/index.tsx b/static/app/views/integrationOrganizationLink/index.tsx index 3c366c776195..703850c796e9 100644 --- a/static/app/views/integrationOrganizationLink/index.tsx +++ b/static/app/views/integrationOrganizationLink/index.tsx @@ -5,7 +5,6 @@ import {skipToken, useQuery} from '@tanstack/react-query'; import {Alert} from '@sentry/scraps/alert'; import {Button} from '@sentry/scraps/button'; import type {SelectOption} from '@sentry/scraps/compactSelect'; -import {ExternalLink} from '@sentry/scraps/link'; import {Select} from '@sentry/scraps/select'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; @@ -36,16 +35,7 @@ import {useParams} from 'sentry/utils/useParams'; import {RouteError} from 'sentry/views/routeError'; import {IntegrationLayout} from 'sentry/views/settings/organizationIntegrations/detailedView/integrationLayout'; -interface GitHubIntegrationInstallation { - account: { - login: string; - type: string; - }; - sender: { - id: number; - login: string; - }; -} +import {GitHubInstallationCallout} from './gitHubInstallationCallout'; function trackExternalAnalytics({ eventName, @@ -127,20 +117,6 @@ export default function IntegrationOrganizationLink() { } }, [isProviderQueryEnabled, providerQuery.error, providerQuery.isPending, provider]); - const isInstallationQueryEnabled = !!installationId && integrationSlug === 'github'; - const installationQuery = useApiQuery( - // @ts-expect-error TODO(ryan953): Invalid useApiQuery path - [`/../../extensions/github/installation/${installationId}/`], - {staleTime: Infinity, enabled: isInstallationQueryEnabled} - ); - const installationData = installationQuery.data ?? null; - - useEffect(() => { - if (isInstallationQueryEnabled && installationQuery.error) { - addErrorMessage(t('Failed to retrieve GitHub installation details')); - } - }, [isInstallationQueryEnabled, installationQuery.error]); - // These two queries are recomputed when an organization is selected const isPendingSelection = (hasSelectedOrg && organizationQuery.isPending) || @@ -303,55 +279,6 @@ export default function IntegrationOrganizationLink() { selectedOrgSlug, ]); - const renderCallout = useCallback(() => { - if (integrationSlug !== 'github') { - return null; - } - - if (!installationData) { - return ( - - - {t( - 'We could not verify the authenticity of the installation request. We recommend restarting the installation process.' - )} - - - ); - } - - const sender_url = `https://github.com/${installationData?.sender.login}`; - const target_url = `https://github.com/${installationData?.account.login}`; - - const alertText = tct( - 'GitHub user [sender_login] has installed GitHub app to [account_type] [account_login]. Proceed if you want to attach this installation to your Sentry account.', - { - account_type: {installationData?.account.type}, - account_login: ( - - - {installationData?.account.login} - - - ), - sender_id: {installationData?.sender.id}, - sender_login: ( - - - {installationData?.sender.login} - - - ), - } - ); - - return ( - - {alertText} - - ); - }, [integrationSlug, installationData]); - if (isPendingOrganizations) { return ; } @@ -376,7 +303,9 @@ export default function IntegrationOrganizationLink() { {t('Finish integration installation')} - {renderCallout()} + {integrationSlug === 'github' && installationId && ( + + )} {tct( `Please pick a specific [organization:organization] to link with
{tct( `Please pick a specific [organization:organization] to link with