From 6f1e1b54d6b69686db0997c55f399b2c65c60b3a Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Wed, 15 Apr 2026 16:11:49 -0400 Subject: [PATCH] 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) --- .../integrations/pagerduty/integration.py | 55 ++++++++- .../pipelineIntegrationPagerDuty.spec.tsx | 110 ++++++++++++++++++ .../pipeline/pipelineIntegrationPagerDuty.tsx | 88 ++++++++++++++ .../pagerduty/test_integration.py | 98 +++++++++++++++- 4 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 static/app/components/pipeline/pipelineIntegrationPagerDuty.spec.tsx create mode 100644 static/app/components/pipeline/pipelineIntegrationPagerDuty.tsx diff --git a/src/sentry/integrations/pagerduty/integration.py b/src/sentry/integrations/pagerduty/integration.py index 4c235ab7728ad5..acbc04860ef34b 100644 --- a/src/sentry/integrations/pagerduty/integration.py +++ b/src/sentry/integrations/pagerduty/integration.py @@ -10,8 +10,11 @@ from django.http.request import HttpRequest from django.http.response import HttpResponseBase from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers +from rest_framework.fields import CharField from sentry import options +from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer from sentry.integrations.base import ( FeatureDescription, IntegrationData, @@ -27,7 +30,8 @@ from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.types import IntegrationProviderSlug from sentry.organizations.services.organization.model import RpcOrganization -from sentry.pipeline.views.base import PipelineView +from sentry.pipeline.types import PipelineStepResult +from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView from sentry.shared_integrations.exceptions import IntegrationError from sentry.utils.http import absolute_uri @@ -186,6 +190,52 @@ def services(self) -> list[PagerDutyServiceDict]: return [] +class PagerDutyInstallationData(TypedDict): + config: str + + +class PagerDutyInstallationApiSerializer(CamelSnakeSerializer[PagerDutyInstallationData]): + config = CharField(required=True) + + def validate_config(self, value: str) -> str: + try: + orjson.loads(value) + except orjson.JSONDecodeError: + raise serializers.ValidationError("Invalid JSON configuration data.") + return value + + +class PagerDutyInstallationApiStep: + """API-mode step for PagerDuty integration setup. + + PagerDuty uses an app install redirect flow: the user is sent to PagerDuty's + install page, and the callback returns a JSON config param containing account + info and integration keys. + """ + + step_name = "installation_redirect" + + def _get_app_url(self) -> str: + app_id = options.get("pagerduty.app-id") + setup_url = absolute_uri("/extensions/pagerduty/setup/") + return f"https://app.pagerduty.com/install/integration?app_id={app_id}&redirect_url={setup_url}&version=2" + + def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, str]: + return {"installUrl": self._get_app_url()} + + def get_serializer_cls(self) -> type: + return PagerDutyInstallationApiSerializer + + def handle_post( + self, + validated_data: dict[str, str], + pipeline: IntegrationPipeline, + request: HttpRequest, + ) -> PipelineStepResult: + pipeline.bind_state("config", validated_data["config"]) + return PipelineStepResult.advance() + + class PagerDutyIntegrationProvider(IntegrationProvider): key = IntegrationProviderSlug.PAGERDUTY.value name = "PagerDuty" @@ -198,6 +248,9 @@ class PagerDutyIntegrationProvider(IntegrationProvider): def get_pipeline_views(self) -> Sequence[PipelineView[IntegrationPipeline]]: return [PagerDutyInstallationRedirect()] + def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]: + return [PagerDutyInstallationApiStep()] + def post_install( self, integration: Integration, diff --git a/static/app/components/pipeline/pipelineIntegrationPagerDuty.spec.tsx b/static/app/components/pipeline/pipelineIntegrationPagerDuty.spec.tsx new file mode 100644 index 00000000000000..470bbcd40bd05d --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationPagerDuty.spec.tsx @@ -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( + overrides: Partial> & {stepData: D} +): PipelineStepProps { + 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; + 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( + + ); + + expect( + screen.getByRole('button', {name: 'Install PagerDuty App'}) + ).toBeInTheDocument(); + }); + + it('calls advance with config on callback', async () => { + const advance = jest.fn(); + render( + + ); + + 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( + + ); + + expect(screen.getByRole('button', {name: 'Installing...'})).toBeDisabled(); + }); + + it('disables install button when installUrl is not provided', () => { + render(); + + expect(screen.getByRole('button', {name: 'Install PagerDuty App'})).toBeDisabled(); + }); +}); diff --git a/static/app/components/pipeline/pipelineIntegrationPagerDuty.tsx b/static/app/components/pipeline/pipelineIntegrationPagerDuty.tsx new file mode 100644 index 00000000000000..262019ad258810 --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationPagerDuty.tsx @@ -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) => { + advance({config: data.config ?? ''}); + }, + [advance] + ); + + const {openPopup, popupStatus} = useRedirectPopupStep({ + redirectUrl: stepData.installUrl, + onCallback: handleCallback, + popup: {height: 900}, + }); + + return ( + + + + {t( + 'Install the Sentry app on your PagerDuty account to complete the integration setup.' + )} + + {popupStatus === 'popup-open' && ( + + {t('A popup should have opened to install the PagerDuty app.')} + + )} + {popupStatus === 'failed-to-open' && ( + + {t( + 'The installation popup was blocked by your browser. Please ensure popups are allowed and try again.' + )} + + )} + + {isAdvancing ? ( + + ) : popupStatus === 'popup-open' ? ( + + ) : ( + + )} + + ); +} + +export const pagerDutyIntegrationPipeline = { + type: 'integration', + provider: 'pagerduty', + actionTitle: t('Installing PagerDuty Integration'), + getCompletionData: pipelineComplete, + completionView: null, + steps: [ + { + stepId: 'installation_redirect', + shortDescription: t('Installing PagerDuty app'), + component: PagerDutyInstallStep, + }, + ], +} as const satisfies PipelineDefinition; diff --git a/tests/sentry/integrations/pagerduty/test_integration.py b/tests/sentry/integrations/pagerduty/test_integration.py index b17cd3891bbbc8..f9abeddeff5766 100644 --- a/tests/sentry/integrations/pagerduty/test_integration.py +++ b/tests/sentry/integrations/pagerduty/test_integration.py @@ -1,19 +1,24 @@ +from __future__ import annotations + +from typing import Any from unittest.mock import patch from urllib.parse import urlencode, urlparse import orjson import pytest import responses +from django.urls import reverse from sentry import options from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.pagerduty.integration import PagerDutyIntegrationProvider from sentry.integrations.pagerduty.utils import get_services +from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.types import EventLifecycleOutcome from sentry.shared_integrations.exceptions import IntegrationError from sentry.testutils.asserts import assert_count_of_metric, assert_success_metric -from sentry.testutils.cases import IntegrationTestCase +from sentry.testutils.cases import APITestCase, IntegrationTestCase from sentry.testutils.silo import control_silo_test @@ -267,3 +272,94 @@ def test_get_config_data(self) -> None: } ] } + + +@control_silo_test +class PagerDutyApiPipelineTest(APITestCase): + endpoint = "sentry-api-0-organization-pipeline" + method = "post" + + def setUp(self) -> None: + super().setUp() + self.login_as(self.user) + self.app_id = "app_1" + options.set("pagerduty.app-id", self.app_id) + + def _get_pipeline_url(self) -> str: + return reverse( + self.endpoint, + args=[self.organization.slug, IntegrationPipeline.pipeline_name], + ) + + def _initialize_pipeline(self) -> Any: + return self.client.post( + self._get_pipeline_url(), + data={"action": "initialize", "provider": "pagerduty"}, + format="json", + ) + + def _advance_step(self, data: dict[str, Any]) -> Any: + return self.client.post(self._get_pipeline_url(), data=data, format="json") + + def _make_config(self, **overrides: Any) -> str: + config = { + "integration_keys": [ + { + "integration_key": "key1", + "name": "Super Cool Service", + "id": "PD12345", + "type": "service", + }, + ], + "account": {"subdomain": "test-app", "name": "Test App"}, + } + config.update(overrides) + return orjson.dumps(config).decode() + + def test_initialize_pipeline(self) -> None: + resp = self._initialize_pipeline() + assert resp.status_code == 200 + assert resp.data["step"] == "installation_redirect" + assert resp.data["stepIndex"] == 0 + assert resp.data["totalSteps"] == 1 + assert resp.data["provider"] == "pagerduty" + assert "installUrl" in resp.data["data"] + install_url = resp.data["data"]["installUrl"] + assert "pagerduty.com/install/integration" in install_url + assert f"app_id={self.app_id}" in install_url + + def test_missing_config(self) -> None: + self._initialize_pipeline() + resp = self._advance_step({}) + assert resp.status_code == 400 + + def test_invalid_json_config(self) -> None: + self._initialize_pipeline() + resp = self._advance_step({"config": "not-valid-json"}) + assert resp.status_code == 400 + + def test_full_pipeline_flow(self) -> None: + resp = self._initialize_pipeline() + assert resp.data["step"] == "installation_redirect" + + resp = self._advance_step({"config": self._make_config()}) + assert resp.status_code == 200 + assert resp.data["status"] == "complete" + assert "data" in resp.data + + integration = Integration.objects.get(provider="pagerduty") + assert integration.external_id == "test-app" + assert integration.name == "Test App" + assert integration.metadata["services"] == [ + { + "integration_key": "key1", + "name": "Super Cool Service", + "id": "PD12345", + "type": "service", + } + ] + + assert OrganizationIntegration.objects.filter( + organization_id=self.organization.id, + integration=integration, + ).exists()