Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(<GitHubInstallationCallout installationId={INSTALLATION_ID} />);

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(<GitHubInstallationCallout installationId={INSTALLATION_ID} />);

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'
);
});
});
});
Original file line number Diff line number Diff line change
@@ -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<T>` `{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<ApiResponse<GitHubIntegrationInstallation>> => {
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 (
<Alert.Container>
<Alert variant="warning">
{t(
'We could not verify the authenticity of the installation request. We recommend restarting the installation process.'
)}
</Alert>
</Alert.Container>
);
}
Comment thread
cursor[bot] marked this conversation as resolved.

const senderUrl = `https://github.com/${data.sender.login}`;
const targetUrl = `https://github.com/${data.account.login}`;

return (
<Alert.Container>
<Alert variant="info">
{tct(
'GitHub user [senderLogin] has installed GitHub app to [accountType] [accountLogin]. Proceed if you want to attach this installation to your Sentry account.',
{
accountType: <strong>{data.account.type}</strong>,
accountLogin: (
<strong>
<ExternalLink href={targetUrl}>{data.account.login}</ExternalLink>
</strong>
),
senderLogin: (
<strong>
<ExternalLink href={senderUrl}>{data.sender.login}</ExternalLink>
</strong>
),
}
)}
</Alert>
</Alert.Container>
);
}
79 changes: 4 additions & 75 deletions static/app/views/integrationOrganizationLink/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -127,20 +117,6 @@ export default function IntegrationOrganizationLink() {
}
}, [isProviderQueryEnabled, providerQuery.error, providerQuery.isPending, provider]);

const isInstallationQueryEnabled = !!installationId && integrationSlug === 'github';
const installationQuery = useApiQuery<GitHubIntegrationInstallation>(
// @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) ||
Expand Down Expand Up @@ -303,55 +279,6 @@ export default function IntegrationOrganizationLink() {
selectedOrgSlug,
]);

const renderCallout = useCallback(() => {
if (integrationSlug !== 'github') {
return null;
}

if (!installationData) {
return (
<Alert.Container>
<Alert variant="warning">
{t(
'We could not verify the authenticity of the installation request. We recommend restarting the installation process.'
)}
</Alert>
</Alert.Container>
);
}

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: <strong>{installationData?.account.type}</strong>,
account_login: (
<strong>
<ExternalLink href={target_url}>
{installationData?.account.login}
</ExternalLink>
</strong>
),
sender_id: <strong>{installationData?.sender.id}</strong>,
sender_login: (
<strong>
<ExternalLink href={sender_url}>
{installationData?.sender.login}
</ExternalLink>
</strong>
),
}
);

return (
<Alert.Container>
<Alert variant="info">{alertText}</Alert>
</Alert.Container>
);
}, [integrationSlug, installationData]);

if (isPendingOrganizations) {
return <LoadingIndicator />;
}
Expand All @@ -376,7 +303,9 @@ export default function IntegrationOrganizationLink() {
<NarrowLayout>
<SentryDocumentTitle title={t('Choose Installation Organization')} />
<h3>{t('Finish integration installation')}</h3>
{renderCallout()}
{integrationSlug === 'github' && installationId && (
<GitHubInstallationCallout installationId={installationId} />
)}
<p>
{tct(
`Please pick a specific [organization:organization] to link with
Expand Down
Loading