-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat(pagerduty): Add API-driven pipeline backend for PagerDuty integration setup #113103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import {act, render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; | ||
|
|
||
| import {pagerDutyIntegrationPipeline} from './pipelineIntegrationPagerDuty'; | ||
| import type {PipelineStepProps} from './types'; | ||
|
|
||
| const PagerDutyInstallStep = pagerDutyIntegrationPipeline.steps[0].component; | ||
|
|
||
| function makeStepProps<D, A>( | ||
| overrides: Partial<PipelineStepProps<D, A>> & {stepData: D} | ||
| ): PipelineStepProps<D, A> { | ||
| return { | ||
| advance: jest.fn(), | ||
| advanceError: null, | ||
| isAdvancing: false, | ||
| stepIndex: 0, | ||
| totalSteps: 1, | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| let mockPopup: Window; | ||
|
|
||
| function dispatchPipelineMessage({ | ||
| data, | ||
| origin = document.location.origin, | ||
| source = mockPopup, | ||
| }: { | ||
| data: Record<string, string>; | ||
| origin?: string; | ||
| source?: Window | MessageEventSource | null; | ||
| }) { | ||
| act(() => { | ||
| const event = new MessageEvent('message', {data, origin}); | ||
| Object.defineProperty(event, 'source', {value: source}); | ||
| window.dispatchEvent(event); | ||
| }); | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| mockPopup = { | ||
| closed: false, | ||
| close: jest.fn(), | ||
| focus: jest.fn(), | ||
| } as unknown as Window; | ||
| jest.spyOn(window, 'open').mockReturnValue(mockPopup); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| jest.restoreAllMocks(); | ||
| }); | ||
|
|
||
| describe('PagerDutyInstallStep', () => { | ||
| it('renders the install step for PagerDuty', () => { | ||
| render( | ||
| <PagerDutyInstallStep | ||
| {...makeStepProps({ | ||
| stepData: {installUrl: 'https://app.pagerduty.com/install/integration'}, | ||
| })} | ||
| /> | ||
| ); | ||
|
|
||
| expect( | ||
| screen.getByRole('button', {name: 'Install PagerDuty App'}) | ||
| ).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('calls advance with config on callback', async () => { | ||
| const advance = jest.fn(); | ||
| render( | ||
| <PagerDutyInstallStep | ||
| {...makeStepProps({ | ||
| stepData: {installUrl: 'https://app.pagerduty.com/install/integration'}, | ||
| advance, | ||
| })} | ||
| /> | ||
| ); | ||
|
|
||
| await userEvent.click(screen.getByRole('button', {name: 'Install PagerDuty App'})); | ||
|
|
||
| dispatchPipelineMessage({ | ||
| data: { | ||
| _pipeline_source: 'sentry-pipeline', | ||
| config: '{"account":{"name":"Test","subdomain":"test"}}', | ||
| }, | ||
| }); | ||
|
|
||
| expect(advance).toHaveBeenCalledWith({ | ||
| config: '{"account":{"name":"Test","subdomain":"test"}}', | ||
| }); | ||
| }); | ||
|
|
||
| it('shows loading state when isAdvancing is true', () => { | ||
| render( | ||
| <PagerDutyInstallStep | ||
| {...makeStepProps({ | ||
| stepData: {installUrl: 'https://app.pagerduty.com/install/integration'}, | ||
| isAdvancing: true, | ||
| })} | ||
| /> | ||
| ); | ||
|
|
||
| expect(screen.getByRole('button', {name: 'Installing...'})).toBeDisabled(); | ||
| }); | ||
|
|
||
| it('disables install button when installUrl is not provided', () => { | ||
| render(<PagerDutyInstallStep {...makeStepProps({stepData: {}})} />); | ||
|
|
||
| expect(screen.getByRole('button', {name: 'Install PagerDuty App'})).toBeDisabled(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import {useCallback} from 'react'; | ||
|
|
||
| import {Button} from '@sentry/scraps/button'; | ||
| import {Stack} from '@sentry/scraps/layout'; | ||
| import {Text} from '@sentry/scraps/text'; | ||
|
|
||
| import {t} from 'sentry/locale'; | ||
| import type {IntegrationWithConfig} from 'sentry/types/integrations'; | ||
|
|
||
| import {useRedirectPopupStep} from './shared/useRedirectPopupStep'; | ||
| import type {PipelineDefinition, PipelineStepProps} from './types'; | ||
| import {pipelineComplete} from './types'; | ||
|
|
||
| function PagerDutyInstallStep({ | ||
| stepData, | ||
| advance, | ||
| isAdvancing, | ||
| }: PipelineStepProps<{installUrl?: string}, {config: string}>) { | ||
| const handleCallback = useCallback( | ||
| (data: Record<string, string>) => { | ||
| advance({config: data.config ?? ''}); | ||
| }, | ||
| [advance] | ||
| ); | ||
|
|
||
| const {openPopup, popupStatus} = useRedirectPopupStep({ | ||
| redirectUrl: stepData.installUrl, | ||
| onCallback: handleCallback, | ||
| popup: {height: 900}, | ||
| }); | ||
|
|
||
| return ( | ||
| <Stack gap="lg" align="start"> | ||
| <Stack gap="sm"> | ||
| <Text> | ||
| {t( | ||
| 'Install the Sentry app on your PagerDuty account to complete the integration setup.' | ||
| )} | ||
| </Text> | ||
| {popupStatus === 'popup-open' && ( | ||
| <Text variant="muted" size="sm"> | ||
| {t('A popup should have opened to install the PagerDuty app.')} | ||
| </Text> | ||
| )} | ||
| {popupStatus === 'failed-to-open' && ( | ||
| <Text variant="danger" size="sm"> | ||
| {t( | ||
| 'The installation popup was blocked by your browser. Please ensure popups are allowed and try again.' | ||
| )} | ||
| </Text> | ||
| )} | ||
| </Stack> | ||
| {isAdvancing ? ( | ||
| <Button size="sm" disabled> | ||
| {t('Installing...')} | ||
| </Button> | ||
| ) : popupStatus === 'popup-open' ? ( | ||
| <Button size="sm" onClick={openPopup}> | ||
| {t('Reopen installation window')} | ||
| </Button> | ||
| ) : ( | ||
| <Button | ||
| size="sm" | ||
| priority="primary" | ||
| onClick={openPopup} | ||
| disabled={!stepData.installUrl} | ||
| > | ||
| {t('Install PagerDuty App')} | ||
| </Button> | ||
| )} | ||
| </Stack> | ||
| ); | ||
| } | ||
|
|
||
| export const pagerDutyIntegrationPipeline = { | ||
| type: 'integration', | ||
| provider: 'pagerduty', | ||
| actionTitle: t('Installing PagerDuty Integration'), | ||
| getCompletionData: pipelineComplete<IntegrationWithConfig>, | ||
| completionView: null, | ||
| steps: [ | ||
| { | ||
| stepId: 'installation_redirect', | ||
| shortDescription: t('Installing PagerDuty app'), | ||
| component: PagerDutyInstallStep, | ||
| }, | ||
| ], | ||
| } as const satisfies PipelineDefinition; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. PagerDuty pipeline not registered in frontend registryHigh Severity
Reviewed by Cursor Bugbot for commit 6f1e1b5. Configure here. |
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Callback advances with empty string on missing config
Low Severity
The
handleCallbackunconditionally callsadvance({config: data.config ?? ''}), sending an empty string to the backend ifdata.configis undefined. Other integrations like Bitbucket guard against missing data by checking before advancing. While the backend JSON validation catches this, the user gets a confusing validation error instead of the step simply waiting for valid callback data.Reviewed by Cursor Bugbot for commit 6f1e1b5. Configure here.