Skip to content

Commit 6f1e1b5

Browse files
committed
feat(pagerduty): Add API-driven pipeline backend for PagerDuty integration setup
Implement get_pipeline_api_steps() on PagerDutyIntegrationProvider with a single installation redirect step. The step returns the PagerDuty app install URL and accepts the JSON config param from the callback. Adds JSON validation on the serializer to catch malformed config before it reaches build_integration(). Refs [VDY-84](https://linear.app/getsentry/issue/VDY-84/pagerduty-api-driven-integration-setup)
1 parent 1093817 commit 6f1e1b5

File tree

4 files changed

+349
-2
lines changed

4 files changed

+349
-2
lines changed

src/sentry/integrations/pagerduty/integration.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010
from django.http.request import HttpRequest
1111
from django.http.response import HttpResponseBase
1212
from django.utils.translation import gettext_lazy as _
13+
from rest_framework import serializers
14+
from rest_framework.fields import CharField
1315

1416
from sentry import options
17+
from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer
1518
from sentry.integrations.base import (
1619
FeatureDescription,
1720
IntegrationData,
@@ -27,7 +30,8 @@
2730
from sentry.integrations.pipeline import IntegrationPipeline
2831
from sentry.integrations.types import IntegrationProviderSlug
2932
from sentry.organizations.services.organization.model import RpcOrganization
30-
from sentry.pipeline.views.base import PipelineView
33+
from sentry.pipeline.types import PipelineStepResult
34+
from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView
3135
from sentry.shared_integrations.exceptions import IntegrationError
3236
from sentry.utils.http import absolute_uri
3337

@@ -186,6 +190,52 @@ def services(self) -> list[PagerDutyServiceDict]:
186190
return []
187191

188192

193+
class PagerDutyInstallationData(TypedDict):
194+
config: str
195+
196+
197+
class PagerDutyInstallationApiSerializer(CamelSnakeSerializer[PagerDutyInstallationData]):
198+
config = CharField(required=True)
199+
200+
def validate_config(self, value: str) -> str:
201+
try:
202+
orjson.loads(value)
203+
except orjson.JSONDecodeError:
204+
raise serializers.ValidationError("Invalid JSON configuration data.")
205+
return value
206+
207+
208+
class PagerDutyInstallationApiStep:
209+
"""API-mode step for PagerDuty integration setup.
210+
211+
PagerDuty uses an app install redirect flow: the user is sent to PagerDuty's
212+
install page, and the callback returns a JSON config param containing account
213+
info and integration keys.
214+
"""
215+
216+
step_name = "installation_redirect"
217+
218+
def _get_app_url(self) -> str:
219+
app_id = options.get("pagerduty.app-id")
220+
setup_url = absolute_uri("/extensions/pagerduty/setup/")
221+
return f"https://app.pagerduty.com/install/integration?app_id={app_id}&redirect_url={setup_url}&version=2"
222+
223+
def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, str]:
224+
return {"installUrl": self._get_app_url()}
225+
226+
def get_serializer_cls(self) -> type:
227+
return PagerDutyInstallationApiSerializer
228+
229+
def handle_post(
230+
self,
231+
validated_data: dict[str, str],
232+
pipeline: IntegrationPipeline,
233+
request: HttpRequest,
234+
) -> PipelineStepResult:
235+
pipeline.bind_state("config", validated_data["config"])
236+
return PipelineStepResult.advance()
237+
238+
189239
class PagerDutyIntegrationProvider(IntegrationProvider):
190240
key = IntegrationProviderSlug.PAGERDUTY.value
191241
name = "PagerDuty"
@@ -198,6 +248,9 @@ class PagerDutyIntegrationProvider(IntegrationProvider):
198248
def get_pipeline_views(self) -> Sequence[PipelineView[IntegrationPipeline]]:
199249
return [PagerDutyInstallationRedirect()]
200250

251+
def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]:
252+
return [PagerDutyInstallationApiStep()]
253+
201254
def post_install(
202255
self,
203256
integration: Integration,
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import {act, render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
2+
3+
import {pagerDutyIntegrationPipeline} from './pipelineIntegrationPagerDuty';
4+
import type {PipelineStepProps} from './types';
5+
6+
const PagerDutyInstallStep = pagerDutyIntegrationPipeline.steps[0].component;
7+
8+
function makeStepProps<D, A>(
9+
overrides: Partial<PipelineStepProps<D, A>> & {stepData: D}
10+
): PipelineStepProps<D, A> {
11+
return {
12+
advance: jest.fn(),
13+
advanceError: null,
14+
isAdvancing: false,
15+
stepIndex: 0,
16+
totalSteps: 1,
17+
...overrides,
18+
};
19+
}
20+
21+
let mockPopup: Window;
22+
23+
function dispatchPipelineMessage({
24+
data,
25+
origin = document.location.origin,
26+
source = mockPopup,
27+
}: {
28+
data: Record<string, string>;
29+
origin?: string;
30+
source?: Window | MessageEventSource | null;
31+
}) {
32+
act(() => {
33+
const event = new MessageEvent('message', {data, origin});
34+
Object.defineProperty(event, 'source', {value: source});
35+
window.dispatchEvent(event);
36+
});
37+
}
38+
39+
beforeEach(() => {
40+
mockPopup = {
41+
closed: false,
42+
close: jest.fn(),
43+
focus: jest.fn(),
44+
} as unknown as Window;
45+
jest.spyOn(window, 'open').mockReturnValue(mockPopup);
46+
});
47+
48+
afterEach(() => {
49+
jest.restoreAllMocks();
50+
});
51+
52+
describe('PagerDutyInstallStep', () => {
53+
it('renders the install step for PagerDuty', () => {
54+
render(
55+
<PagerDutyInstallStep
56+
{...makeStepProps({
57+
stepData: {installUrl: 'https://app.pagerduty.com/install/integration'},
58+
})}
59+
/>
60+
);
61+
62+
expect(
63+
screen.getByRole('button', {name: 'Install PagerDuty App'})
64+
).toBeInTheDocument();
65+
});
66+
67+
it('calls advance with config on callback', async () => {
68+
const advance = jest.fn();
69+
render(
70+
<PagerDutyInstallStep
71+
{...makeStepProps({
72+
stepData: {installUrl: 'https://app.pagerduty.com/install/integration'},
73+
advance,
74+
})}
75+
/>
76+
);
77+
78+
await userEvent.click(screen.getByRole('button', {name: 'Install PagerDuty App'}));
79+
80+
dispatchPipelineMessage({
81+
data: {
82+
_pipeline_source: 'sentry-pipeline',
83+
config: '{"account":{"name":"Test","subdomain":"test"}}',
84+
},
85+
});
86+
87+
expect(advance).toHaveBeenCalledWith({
88+
config: '{"account":{"name":"Test","subdomain":"test"}}',
89+
});
90+
});
91+
92+
it('shows loading state when isAdvancing is true', () => {
93+
render(
94+
<PagerDutyInstallStep
95+
{...makeStepProps({
96+
stepData: {installUrl: 'https://app.pagerduty.com/install/integration'},
97+
isAdvancing: true,
98+
})}
99+
/>
100+
);
101+
102+
expect(screen.getByRole('button', {name: 'Installing...'})).toBeDisabled();
103+
});
104+
105+
it('disables install button when installUrl is not provided', () => {
106+
render(<PagerDutyInstallStep {...makeStepProps({stepData: {}})} />);
107+
108+
expect(screen.getByRole('button', {name: 'Install PagerDuty App'})).toBeDisabled();
109+
});
110+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import {useCallback} from 'react';
2+
3+
import {Button} from '@sentry/scraps/button';
4+
import {Stack} from '@sentry/scraps/layout';
5+
import {Text} from '@sentry/scraps/text';
6+
7+
import {t} from 'sentry/locale';
8+
import type {IntegrationWithConfig} from 'sentry/types/integrations';
9+
10+
import {useRedirectPopupStep} from './shared/useRedirectPopupStep';
11+
import type {PipelineDefinition, PipelineStepProps} from './types';
12+
import {pipelineComplete} from './types';
13+
14+
function PagerDutyInstallStep({
15+
stepData,
16+
advance,
17+
isAdvancing,
18+
}: PipelineStepProps<{installUrl?: string}, {config: string}>) {
19+
const handleCallback = useCallback(
20+
(data: Record<string, string>) => {
21+
advance({config: data.config ?? ''});
22+
},
23+
[advance]
24+
);
25+
26+
const {openPopup, popupStatus} = useRedirectPopupStep({
27+
redirectUrl: stepData.installUrl,
28+
onCallback: handleCallback,
29+
popup: {height: 900},
30+
});
31+
32+
return (
33+
<Stack gap="lg" align="start">
34+
<Stack gap="sm">
35+
<Text>
36+
{t(
37+
'Install the Sentry app on your PagerDuty account to complete the integration setup.'
38+
)}
39+
</Text>
40+
{popupStatus === 'popup-open' && (
41+
<Text variant="muted" size="sm">
42+
{t('A popup should have opened to install the PagerDuty app.')}
43+
</Text>
44+
)}
45+
{popupStatus === 'failed-to-open' && (
46+
<Text variant="danger" size="sm">
47+
{t(
48+
'The installation popup was blocked by your browser. Please ensure popups are allowed and try again.'
49+
)}
50+
</Text>
51+
)}
52+
</Stack>
53+
{isAdvancing ? (
54+
<Button size="sm" disabled>
55+
{t('Installing...')}
56+
</Button>
57+
) : popupStatus === 'popup-open' ? (
58+
<Button size="sm" onClick={openPopup}>
59+
{t('Reopen installation window')}
60+
</Button>
61+
) : (
62+
<Button
63+
size="sm"
64+
priority="primary"
65+
onClick={openPopup}
66+
disabled={!stepData.installUrl}
67+
>
68+
{t('Install PagerDuty App')}
69+
</Button>
70+
)}
71+
</Stack>
72+
);
73+
}
74+
75+
export const pagerDutyIntegrationPipeline = {
76+
type: 'integration',
77+
provider: 'pagerduty',
78+
actionTitle: t('Installing PagerDuty Integration'),
79+
getCompletionData: pipelineComplete<IntegrationWithConfig>,
80+
completionView: null,
81+
steps: [
82+
{
83+
stepId: 'installation_redirect',
84+
shortDescription: t('Installing PagerDuty app'),
85+
component: PagerDutyInstallStep,
86+
},
87+
],
88+
} as const satisfies PipelineDefinition;

0 commit comments

Comments
 (0)