From 652c594816de8867e6c50d1b8b20e44aab04139f Mon Sep 17 00:00:00 2001 From: Mia Hsu Date: Wed, 6 Aug 2025 16:22:37 -0700 Subject: [PATCH 1/7] feat(aci): add alertrule/workflow GET endpoint --- .../organization_alertrule_workflow_index.py | 71 +++++++++++++++++ src/sentry/workflow_engine/endpoints/urls.py | 9 +++ .../validators/alertrule_workflow.py | 19 +++++ .../test_organization_alertrule_workflow.py | 78 +++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 src/sentry/workflow_engine/endpoints/organization_alertrule_workflow_index.py create mode 100644 src/sentry/workflow_engine/endpoints/validators/alertrule_workflow.py create mode 100644 tests/sentry/workflow_engine/endpoints/test_organization_alertrule_workflow.py diff --git a/src/sentry/workflow_engine/endpoints/organization_alertrule_workflow_index.py b/src/sentry/workflow_engine/endpoints/organization_alertrule_workflow_index.py new file mode 100644 index 00000000000000..bade4e9a51b19c --- /dev/null +++ b/src/sentry/workflow_engine/endpoints/organization_alertrule_workflow_index.py @@ -0,0 +1,71 @@ +from drf_spectacular.utils import extend_schema +from rest_framework.response import Response + +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases import OrganizationEndpoint +from sentry.api.bases.organization import OrganizationDetectorPermission +from sentry.api.exceptions import ResourceDoesNotExist +from sentry.api.serializers import serialize +from sentry.apidocs.constants import ( + RESPONSE_BAD_REQUEST, + RESPONSE_FORBIDDEN, + RESPONSE_NOT_FOUND, + RESPONSE_UNAUTHORIZED, +) +from sentry.apidocs.parameters import GlobalParams +from sentry.workflow_engine.endpoints.serializers import AlertRuleWorkflowSerializer +from sentry.workflow_engine.endpoints.validators.alertrule_workflow import ( + AlertRuleWorkflowValidator, +) +from sentry.workflow_engine.models.alertrule_workflow import AlertRuleWorkflow + + +@region_silo_endpoint +class OrganizationAlertRuleWorkflowIndexEndpoint(OrganizationEndpoint): + publish_status = { + "GET": ApiPublishStatus.EXPERIMENTAL, + } + owner = ApiOwner.ISSUES + permission_classes = (OrganizationDetectorPermission,) + + @extend_schema( + operation_id="Fetch Dual-Written Rule/Alert Rules and Workflows", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + ], + responses={ + 201: AlertRuleWorkflowSerializer, + 400: RESPONSE_BAD_REQUEST, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + ) + def get(self, request, organization): + """ + Returns a dual-written rule/alert rule and its associated workflow. + """ + validator = AlertRuleWorkflowValidator(data=request.query_params) + validator.is_valid(raise_exception=True) + rule_id = validator.validated_data.get("rule_id") + alert_rule_id = validator.validated_data.get("alert_rule_id") + workflow_id = validator.validated_data.get("workflow_id") + + queryset = AlertRuleWorkflow.objects.filter(workflow__organization=organization) + + if workflow_id: + queryset = queryset.filter(workflow_id=workflow_id) + + if alert_rule_id: + queryset = queryset.filter(alert_rule_id=alert_rule_id) + + if rule_id: + queryset = queryset.filter(rule_id=rule_id) + + alert_rule_workflow = queryset.first() + if not alert_rule_workflow: + raise ResourceDoesNotExist + + return Response(serialize(alert_rule_workflow, request.user)) diff --git a/src/sentry/workflow_engine/endpoints/urls.py b/src/sentry/workflow_engine/endpoints/urls.py index eb084298f108fa..3cccb0bca6e12d 100644 --- a/src/sentry/workflow_engine/endpoints/urls.py +++ b/src/sentry/workflow_engine/endpoints/urls.py @@ -1,5 +1,9 @@ from django.urls import re_path +from sentry.workflow_engine.endpoints.organization_alertrule_workflow_index import ( + OrganizationAlertRuleWorkflowIndexEndpoint, +) + from .organization_available_action_index import OrganizationAvailableActionIndexEndpoint from .organization_data_condition_index import OrganizationDataConditionIndexEndpoint from .organization_detector_count import OrganizationDetectorCountEndpoint @@ -97,4 +101,9 @@ OrganizationOpenPeriodsEndpoint.as_view(), name="sentry-api-0-organization-open-periods", ), + re_path( + r"^(?P[^/]+)/alert-rule-workflow/$", + OrganizationAlertRuleWorkflowIndexEndpoint.as_view(), + name="sentry-api-0-organization-alert-rule-workflow-index", + ), ] diff --git a/src/sentry/workflow_engine/endpoints/validators/alertrule_workflow.py b/src/sentry/workflow_engine/endpoints/validators/alertrule_workflow.py new file mode 100644 index 00000000000000..3906c2d01dd35f --- /dev/null +++ b/src/sentry/workflow_engine/endpoints/validators/alertrule_workflow.py @@ -0,0 +1,19 @@ +from rest_framework import serializers + + +class AlertRuleWorkflowValidator(serializers.Serializer): + rule_id = serializers.CharField(required=False) + alert_rule_id = serializers.CharField(required=False) + workflow_id = serializers.CharField(required=False) + + def validate(self, attrs): + super().validate(attrs) + if ( + not attrs.get("rule_id") + and not attrs.get("alert_rule_id") + and not attrs.get("workflow_id") + ): + raise serializers.ValidationError( + "'rule_id', 'alert_rule_id', or 'workflow_id' must be provided." + ) + return attrs diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_alertrule_workflow.py b/tests/sentry/workflow_engine/endpoints/test_organization_alertrule_workflow.py new file mode 100644 index 00000000000000..af2659b8431c18 --- /dev/null +++ b/tests/sentry/workflow_engine/endpoints/test_organization_alertrule_workflow.py @@ -0,0 +1,78 @@ +from sentry.api.serializers import serialize +from sentry.testutils.cases import APITestCase +from sentry.testutils.silo import region_silo_test + + +class OrganizationAlertRuleWorkflowAPITestCase(APITestCase): + endpoint = "sentry-api-0-organization-alert-rule-workflow-index" + + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + + self.workflow_1 = self.create_workflow(organization=self.organization) + self.workflow_2 = self.create_workflow(organization=self.organization) + self.workflow_3 = self.create_workflow(organization=self.organization) + + self.alert_rule_workflow_1 = self.create_alert_rule_workflow( + alert_rule_id=12345, workflow=self.workflow_1 + ) + self.alert_rule_workflow_2 = self.create_alert_rule_workflow( + rule_id=67890, workflow=self.workflow_2 + ) + self.alert_rule_workflow_3 = self.create_alert_rule_workflow( + alert_rule_id=11111, workflow=self.workflow_3 + ) + + # Create workflow in different organization to test filtering + self.other_org = self.create_organization() + self.other_workflow = self.create_workflow(organization=self.other_org) + self.other_alert_rule_workflow = self.create_alert_rule_workflow( + alert_rule_id=99999, workflow=self.other_workflow + ) + + +@region_silo_test +class OrganizationAlertRuleWorkflowIndexGetTest(OrganizationAlertRuleWorkflowAPITestCase): + def test_get_with_workflow_id_filter(self) -> None: + response = self.get_success_response( + self.organization.slug, workflow_id=str(self.workflow_1.id) + ) + assert response.data == serialize(self.alert_rule_workflow_1, self.user) + + def test_get_with_alert_rule_id_filter(self) -> None: + response = self.get_success_response(self.organization.slug, alert_rule_id="12345") + assert response.data == serialize(self.alert_rule_workflow_1, self.user) + + assert response.data["alertRuleId"] == "12345" + assert response.data["ruleId"] is None + assert response.data["workflowId"] == str(self.workflow_1.id) + + def test_get_with_rule_id_filter(self) -> None: + response = self.get_success_response(self.organization.slug, rule_id="67890") + assert response.data == serialize(self.alert_rule_workflow_2, self.user) + + assert response.data["ruleId"] == "67890" + assert response.data["alertRuleId"] is None + assert response.data["workflowId"] == str(self.workflow_2.id) + + def test_get_with_nonexistent_workflow_id(self) -> None: + self.get_error_response(self.organization.slug, workflow_id="99999", status_code=404) + + def test_get_with_nonexistent_alert_rule_id(self) -> None: + self.get_error_response(self.organization.slug, alert_rule_id="99999", status_code=404) + + def test_get_with_nonexistent_rule_id(self) -> None: + self.get_error_response(self.organization.slug, rule_id="99999", status_code=404) + + def test_get_with_multiple_filters(self) -> None: + self.get_error_response( + self.organization.slug, + workflow_id=str(self.workflow_1.id), + alert_rule_id="12345", + ) + + def test_organization_isolation(self) -> None: + self.get_error_response( + self.organization.slug, workflow_id=str(self.other_workflow.id), status_code=404 + ) From 9bca0e492f896350cf95f490ee3411e8b058b443 Mon Sep 17 00:00:00 2001 From: Mia Hsu Date: Wed, 6 Aug 2025 16:53:13 -0700 Subject: [PATCH 2/7] fix tests --- .../test_organization_alertrule_workflow.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_alertrule_workflow.py b/tests/sentry/workflow_engine/endpoints/test_organization_alertrule_workflow.py index af2659b8431c18..d1d3fc0f54664f 100644 --- a/tests/sentry/workflow_engine/endpoints/test_organization_alertrule_workflow.py +++ b/tests/sentry/workflow_engine/endpoints/test_organization_alertrule_workflow.py @@ -42,7 +42,6 @@ def test_get_with_workflow_id_filter(self) -> None: def test_get_with_alert_rule_id_filter(self) -> None: response = self.get_success_response(self.organization.slug, alert_rule_id="12345") - assert response.data == serialize(self.alert_rule_workflow_1, self.user) assert response.data["alertRuleId"] == "12345" assert response.data["ruleId"] is None @@ -50,12 +49,20 @@ def test_get_with_alert_rule_id_filter(self) -> None: def test_get_with_rule_id_filter(self) -> None: response = self.get_success_response(self.organization.slug, rule_id="67890") - assert response.data == serialize(self.alert_rule_workflow_2, self.user) assert response.data["ruleId"] == "67890" assert response.data["alertRuleId"] is None assert response.data["workflowId"] == str(self.workflow_2.id) + def test_get_with_multiple_filters(self) -> None: + response = self.get_success_response( + self.organization.slug, + workflow_id=str(self.workflow_1.id), + alert_rule_id="12345", + ) + + assert response.data == serialize(self.alert_rule_workflow_1, self.user) + def test_get_with_nonexistent_workflow_id(self) -> None: self.get_error_response(self.organization.slug, workflow_id="99999", status_code=404) @@ -65,13 +72,6 @@ def test_get_with_nonexistent_alert_rule_id(self) -> None: def test_get_with_nonexistent_rule_id(self) -> None: self.get_error_response(self.organization.slug, rule_id="99999", status_code=404) - def test_get_with_multiple_filters(self) -> None: - self.get_error_response( - self.organization.slug, - workflow_id=str(self.workflow_1.id), - alert_rule_id="12345", - ) - def test_organization_isolation(self) -> None: self.get_error_response( self.organization.slug, workflow_id=str(self.other_workflow.id), status_code=404 From d7fb514e06abd3c2503e19bad97a7fb21a057ad6 Mon Sep 17 00:00:00 2001 From: Mia Hsu Date: Wed, 13 Aug 2025 20:03:19 -0700 Subject: [PATCH 3/7] fixes --- .../endpoints/organization_alertrule_workflow_index.py | 2 +- .../endpoints/validators/alertrule_workflow.py | 2 +- .../endpoints/test_organization_alertrule_workflow.py | 7 +++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/sentry/workflow_engine/endpoints/organization_alertrule_workflow_index.py b/src/sentry/workflow_engine/endpoints/organization_alertrule_workflow_index.py index bade4e9a51b19c..3585fe32dfe43e 100644 --- a/src/sentry/workflow_engine/endpoints/organization_alertrule_workflow_index.py +++ b/src/sentry/workflow_engine/endpoints/organization_alertrule_workflow_index.py @@ -36,7 +36,7 @@ class OrganizationAlertRuleWorkflowIndexEndpoint(OrganizationEndpoint): GlobalParams.ORG_ID_OR_SLUG, ], responses={ - 201: AlertRuleWorkflowSerializer, + 200: AlertRuleWorkflowSerializer, 400: RESPONSE_BAD_REQUEST, 401: RESPONSE_UNAUTHORIZED, 403: RESPONSE_FORBIDDEN, diff --git a/src/sentry/workflow_engine/endpoints/validators/alertrule_workflow.py b/src/sentry/workflow_engine/endpoints/validators/alertrule_workflow.py index 3906c2d01dd35f..ea761f52ae0a21 100644 --- a/src/sentry/workflow_engine/endpoints/validators/alertrule_workflow.py +++ b/src/sentry/workflow_engine/endpoints/validators/alertrule_workflow.py @@ -14,6 +14,6 @@ def validate(self, attrs): and not attrs.get("workflow_id") ): raise serializers.ValidationError( - "'rule_id', 'alert_rule_id', or 'workflow_id' must be provided." + "One of 'rule_id', 'alert_rule_id', or 'workflow_id' must be provided." ) return attrs diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_alertrule_workflow.py b/tests/sentry/workflow_engine/endpoints/test_organization_alertrule_workflow.py index d1d3fc0f54664f..e109e80ec318b9 100644 --- a/tests/sentry/workflow_engine/endpoints/test_organization_alertrule_workflow.py +++ b/tests/sentry/workflow_engine/endpoints/test_organization_alertrule_workflow.py @@ -63,6 +63,13 @@ def test_get_with_multiple_filters(self) -> None: assert response.data == serialize(self.alert_rule_workflow_1, self.user) + def test_get_with_multiple_filters_with_invalid_filter(self) -> None: + self.get_error_response( + self.organization.slug, + workflow_id=str(self.workflow_1.id), + alert_rule_id="00000", + ) + def test_get_with_nonexistent_workflow_id(self) -> None: self.get_error_response(self.organization.slug, workflow_id="99999", status_code=404) From bbbee6f00b6317d124f74336ebad4f069403bb23 Mon Sep 17 00:00:00 2001 From: Mia Hsu Date: Thu, 14 Aug 2025 10:41:08 -0700 Subject: [PATCH 4/7] proof that passing non-numeric strings is ok --- .../endpoints/test_organization_alertrule_workflow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_alertrule_workflow.py b/tests/sentry/workflow_engine/endpoints/test_organization_alertrule_workflow.py index e109e80ec318b9..a61b5b727cc3d3 100644 --- a/tests/sentry/workflow_engine/endpoints/test_organization_alertrule_workflow.py +++ b/tests/sentry/workflow_engine/endpoints/test_organization_alertrule_workflow.py @@ -67,7 +67,7 @@ def test_get_with_multiple_filters_with_invalid_filter(self) -> None: self.get_error_response( self.organization.slug, workflow_id=str(self.workflow_1.id), - alert_rule_id="00000", + alert_rule_id="this is not a valid ID", ) def test_get_with_nonexistent_workflow_id(self) -> None: From 933da601a15a00c5a4d6e678c9f4303dd833ae73 Mon Sep 17 00:00:00 2001 From: Mia Hsu Date: Mon, 10 Nov 2025 14:58:41 -0800 Subject: [PATCH 5/7] serializer in separate file --- .../alertrule_workflow_serializer.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/sentry/workflow_engine/endpoints/serializers/alertrule_workflow_serializer.py diff --git a/src/sentry/workflow_engine/endpoints/serializers/alertrule_workflow_serializer.py b/src/sentry/workflow_engine/endpoints/serializers/alertrule_workflow_serializer.py new file mode 100644 index 00000000000000..a6c5c8205512cd --- /dev/null +++ b/src/sentry/workflow_engine/endpoints/serializers/alertrule_workflow_serializer.py @@ -0,0 +1,23 @@ +from collections.abc import Mapping +from typing import Any, TypedDict + +from sentry.api.serializers import Serializer, register +from sentry.workflow_engine.models import AlertRuleWorkflow + + +class ActionHandlerSerializerResponse(TypedDict): + ruleId: str | None + alertRuleId: str | None + workflowId: str + + +@register(AlertRuleWorkflow) +class AlertRuleWorkflowSerializer(Serializer): + def serialize( + self, obj: AlertRuleWorkflow, attrs: Mapping[str, Any], user, **kwargs + ) -> ActionHandlerSerializerResponse: + return { + "ruleId": str(obj.rule_id) if obj.rule_id else None, + "alertRuleId": str(obj.alert_rule_id) if obj.alert_rule_id else None, + "workflowId": str(obj.workflow.id), + } From feb39e9ecf3c31918a8e734af42c3bb515f25cfc Mon Sep 17 00:00:00 2001 From: Mia Hsu Date: Mon, 10 Nov 2025 15:01:46 -0800 Subject: [PATCH 6/7] fix import --- .../endpoints/organization_alertrule_workflow_index.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/sentry/workflow_engine/endpoints/organization_alertrule_workflow_index.py b/src/sentry/workflow_engine/endpoints/organization_alertrule_workflow_index.py index 3585fe32dfe43e..36ab0293075a4a 100644 --- a/src/sentry/workflow_engine/endpoints/organization_alertrule_workflow_index.py +++ b/src/sentry/workflow_engine/endpoints/organization_alertrule_workflow_index.py @@ -15,7 +15,9 @@ RESPONSE_UNAUTHORIZED, ) from sentry.apidocs.parameters import GlobalParams -from sentry.workflow_engine.endpoints.serializers import AlertRuleWorkflowSerializer +from sentry.workflow_engine.endpoints.serializers.alertrule_workflow_serializer import ( + AlertRuleWorkflowSerializer, +) from sentry.workflow_engine.endpoints.validators.alertrule_workflow import ( AlertRuleWorkflowValidator, ) From 4e49345763aa52c931d3bad446bd359585d30504 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 23:04:39 +0000 Subject: [PATCH 7/7] :hammer_and_wrench: Sync API Urls to TypeScirpt --- static/app/utils/api/knownSentryApiUrls.generated.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index d7dd44ddc81fb1..a56be752f17d60 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -121,10 +121,8 @@ export type KnownSentryApiUrls = | '/internal/prevent/pr-review/configs/resolved/' | '/internal/prevent/pr-review/github/sentry-org/' | '/internal/project-config/' - | '/internal/queue/tasks/' | '/internal/rpc/$serviceName/$methodName/' | '/internal/seer-rpc/$methodName/' - | '/internal/stats/' | '/internal/warnings/' | '/issues/$issueId/' | '/issues/$issueId/activities/' @@ -192,6 +190,7 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/access-requests/' | '/organizations/$organizationIdOrSlug/access-requests/$requestId/' | '/organizations/$organizationIdOrSlug/ai-conversations/' + | '/organizations/$organizationIdOrSlug/alert-rule-workflow/' | '/organizations/$organizationIdOrSlug/alert-rules/' | '/organizations/$organizationIdOrSlug/alert-rules/$alertRuleId/' | '/organizations/$organizationIdOrSlug/alert-rules/available-actions/'