Skip to content

Commit 4121329

Browse files
authored
fix(ACI): Fix adding sentry app action to a workflow (#103790)
Fixes adding and updating sentry app actions on a workflow and requires `settings` to be passed for a sentry app action if it has a UI component. If it has no component, `settings` does not need to be passed. Adds support for a `target_type` of either `sentry_app_id` or `sentry_app_installation_uuid` until we can migrate the data to only have `sentry_app_id`. Add tests for adding and updating sentry app actions. Fixes https://sentry.sentry.io/issues/6980690322
1 parent 30cb71a commit 4121329

File tree

6 files changed

+416
-29
lines changed

6 files changed

+416
-29
lines changed

src/sentry/notifications/notification_action/action_handler_registry/sentry_app_handler.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class SentryAppActionHandler(ActionHandler):
3333

3434
data_schema = {
3535
"$schema": "https://json-schema.org/draft/2020-12/schema",
36+
"description": "The data schema for a Sentry App Action",
3637
"type": "object",
3738
"properties": {
3839
"settings": {"type": ["array", "object"]},

src/sentry/notifications/notification_action/action_validation.py

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from sentry.rules.actions.integrations.create_ticket.form import IntegrationNotifyServiceForm
1616
from sentry.rules.actions.notify_event_service import NotifyEventServiceForm
1717
from sentry.rules.actions.sentry_apps.utils import validate_sentry_app_action
18-
from sentry.sentry_apps.services.app.service import app_service
18+
from sentry.sentry_apps.services.app import RpcSentryAppInstallation, app_service
1919
from sentry.sentry_apps.utils.errors import SentryAppBaseError
2020
from sentry.utils import json
2121
from sentry.workflow_engine.models.action import Action
@@ -206,31 +206,74 @@ def __init__(self, validated_data: dict[str, Any], organization: Organization) -
206206
self.validated_data = validated_data
207207
self.organization = organization
208208

209+
def _get_sentry_app_installation(
210+
self, sentry_app_identifier: SentryAppIdentifier, target_identifier: str
211+
) -> RpcSentryAppInstallation | None:
212+
"""
213+
Get the sentry app installation based on whether the target identifier is an installation id or sentry app id
214+
We do not want to accept SentryAppIdentifier.SENTRY_APP_INSTALLATION_UUID long term, this is temporary until we migrate the data over
215+
"""
216+
installations = None
217+
installation = None
218+
219+
if sentry_app_identifier == SentryAppIdentifier.SENTRY_APP_INSTALLATION_UUID:
220+
installations = app_service.get_many(
221+
filter=dict(uuids=[target_identifier], organization_id=self.organization.id)
222+
)
223+
else:
224+
installations = app_service.get_many(
225+
filter=dict(app_ids=[int(target_identifier)], organization_id=self.organization.id)
226+
)
227+
if installations:
228+
installation = installations[0]
229+
230+
return installation
231+
209232
def clean_data(self) -> dict[str, Any]:
210-
is_sentry_app_installation = (
211-
SentryAppIdentifier(self.validated_data["config"]["sentry_app_identifier"])
212-
== SentryAppIdentifier.SENTRY_APP_INSTALLATION_UUID
233+
sentry_app_identifier = SentryAppIdentifier(
234+
self.validated_data["config"]["sentry_app_identifier"]
213235
)
236+
target_identifier = self.validated_data["config"]["target_identifier"]
237+
installation = self._get_sentry_app_installation(sentry_app_identifier, target_identifier)
238+
if not installation:
239+
raise ValidationError("Sentry app installation not found.")
240+
241+
if sentry_app_identifier == SentryAppIdentifier.SENTRY_APP_INSTALLATION_UUID:
242+
# convert to use sentry_app_id until we can migrate all the data
243+
self.validated_data["config"][
244+
"sentry_app_identifier"
245+
] = SentryAppIdentifier.SENTRY_APP_ID
246+
self.validated_data["config"]["target_identifier"] = str(installation.sentry_app.id)
247+
248+
settings = self.validated_data["data"].get("settings", [])
214249
action = {
215-
"settings": self.validated_data["data"]["settings"],
216-
"sentryAppInstallationUuid": (
217-
self.validated_data["config"]["target_identifier"]
218-
if is_sentry_app_installation
219-
else None
220-
),
250+
"settings": settings,
251+
"sentryAppInstallationUuid": installation.uuid,
221252
}
222-
try:
223-
validate_sentry_app_action(action)
224253

254+
if not settings:
255+
# XXX: it's only ok to not pass settings if there is no sentry app schema
256+
# this means the app doesn't expect any settings
257+
components = app_service.find_app_components(app_id=installation.sentry_app.id)
258+
if any(
259+
component.app_schema
260+
for component in components
261+
if component.type == "alert-rule-action"
262+
):
263+
raise ValidationError("'settings' is a required property")
264+
265+
else:
225266
# Sentry app config blob expects value to be a string
226-
settings = self.validated_data["data"]["settings"]
227267
for setting in settings:
228268
if setting.get("value") is not None and not isinstance(setting["value"], str):
229269
setting["value"] = json.dumps(setting["value"])
270+
try:
271+
# Only call creator for Sentry Apps with UI Components (settings) for actions
272+
validate_sentry_app_action(action)
273+
except SentryAppBaseError as e:
274+
raise ValidationError(e.message) from e
230275

231-
return self.validated_data
232-
except SentryAppBaseError as e:
233-
raise ValidationError(e.message) from e
276+
return self.validated_data
234277

235278

236279
@action_validator_registry.register(Action.Type.WEBHOOK)

tests/sentry/workflow_engine/endpoints/test_organization_workflow_details.py

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from contextlib import AbstractContextManager
22

3+
import responses
4+
35
from sentry import audit_log
46
from sentry.api.serializers import serialize
57
from sentry.constants import ObjectStatus
@@ -13,8 +15,20 @@
1315
from sentry.testutils.outbox import outbox_runner
1416
from sentry.testutils.silo import assume_test_silo_mode, region_silo_test
1517
from sentry.workflow_engine.endpoints.validators.base.workflow import WorkflowValidator
16-
from sentry.workflow_engine.models import Action, DataConditionGroup, Workflow
18+
from sentry.workflow_engine.models import (
19+
Action,
20+
Condition,
21+
DataConditionGroup,
22+
DataConditionGroupAction,
23+
Workflow,
24+
WorkflowDataConditionGroup,
25+
)
1726
from sentry.workflow_engine.models.detector_workflow import DetectorWorkflow
27+
from sentry.workflow_engine.typings.notification_action import (
28+
ActionTarget,
29+
ActionType,
30+
SentryAppIdentifier,
31+
)
1832
from tests.sentry.workflow_engine.test_base import BaseWorkflowTest
1933

2034

@@ -54,7 +68,7 @@ def setUp(self) -> None:
5468
"enabled": True,
5569
"config": {},
5670
"triggers": {"logicType": "any", "conditions": []},
57-
"action_filters": [],
71+
"actionFilters": [],
5872
}
5973
validator = WorkflowValidator(
6074
data=self.valid_workflow,
@@ -63,6 +77,15 @@ def setUp(self) -> None:
6377
validator.is_valid(raise_exception=True)
6478
self.workflow = validator.create(validator.validated_data)
6579

80+
self.sentry_app, self.sentry_app_installation = self.create_sentry_app_with_schema()
81+
self.sentry_app_settings = [
82+
{"name": "alert_prefix", "value": "[Not Good]"},
83+
{"name": "channel", "value": "#ignored-errors"},
84+
{"name": "best_emoji", "value": ":fire:"},
85+
{"name": "teamId", "value": "1"},
86+
{"name": "assigneeId", "value": "3"},
87+
]
88+
6689
def test_simple(self) -> None:
6790
self.valid_workflow["name"] = "Updated Workflow"
6891
response = self.get_success_response(
@@ -73,6 +96,116 @@ def test_simple(self) -> None:
7396
assert response.status_code == 200
7497
assert updated_workflow.name == "Updated Workflow"
7598

99+
@responses.activate
100+
def test_update_add_sentry_app_action(self) -> None:
101+
"""
102+
Test that adding a sentry app action to a workflow works as expected
103+
"""
104+
responses.add(
105+
method=responses.POST,
106+
url="https://example.com/sentry/alert-rule",
107+
status=200,
108+
)
109+
self.valid_workflow["actionFilters"] = [
110+
{
111+
"logicType": "any",
112+
"conditions": [
113+
{
114+
"type": Condition.EQUAL.value,
115+
"comparison": 1,
116+
"conditionResult": True,
117+
}
118+
],
119+
"actions": [
120+
{
121+
"config": {
122+
"sentryAppIdentifier": SentryAppIdentifier.SENTRY_APP_ID,
123+
"targetIdentifier": str(self.sentry_app.id),
124+
"targetType": ActionType.SENTRY_APP,
125+
},
126+
"data": {"settings": self.sentry_app_settings},
127+
"type": Action.Type.SENTRY_APP,
128+
},
129+
],
130+
}
131+
]
132+
response = self.get_success_response(
133+
self.organization.slug, self.workflow.id, raw_data=self.valid_workflow
134+
)
135+
updated_workflow = Workflow.objects.get(id=response.data.get("id"))
136+
action_filter = WorkflowDataConditionGroup.objects.get(workflow=updated_workflow)
137+
dcga = DataConditionGroupAction.objects.get(condition_group=action_filter.condition_group)
138+
action = dcga.action
139+
140+
assert response.status_code == 200
141+
assert action.type == Action.Type.SENTRY_APP
142+
assert action.config == {
143+
"sentry_app_identifier": SentryAppIdentifier.SENTRY_APP_ID,
144+
"target_identifier": str(self.sentry_app.id),
145+
"target_type": ActionTarget.SENTRY_APP.value,
146+
}
147+
assert action.data["settings"] == self.sentry_app_settings
148+
149+
@responses.activate
150+
def test_update_add_sentry_app_installation_uuid_action(self) -> None:
151+
"""
152+
Test that adding a sentry app action to a workflow works as expected when we pass the installation UUID instead of the sentry app id.
153+
We do not create new actions like this today, but we have data like this in the DB and need to make sure it still works until we can migrate away from it
154+
to use the sentry app id
155+
"""
156+
responses.add(
157+
method=responses.POST,
158+
url="https://example.com/sentry/alert-rule",
159+
status=200,
160+
)
161+
# update the settings
162+
updated_sentry_app_settings = [
163+
{"name": "alert_prefix", "value": "[Very Good]"},
164+
{"name": "channel", "value": "#pay-attention-to-errors"},
165+
{"name": "best_emoji", "value": ":ice:"},
166+
{"name": "teamId", "value": "1"},
167+
{"name": "assigneeId", "value": "3"},
168+
]
169+
self.valid_workflow["actionFilters"] = [
170+
{
171+
"logicType": "any",
172+
"conditions": [
173+
{
174+
"type": Condition.EQUAL.value,
175+
"comparison": 1,
176+
"conditionResult": True,
177+
}
178+
],
179+
"actions": [
180+
{
181+
"config": {
182+
"sentryAppIdentifier": SentryAppIdentifier.SENTRY_APP_INSTALLATION_UUID,
183+
"targetIdentifier": self.sentry_app_installation.uuid,
184+
"targetType": ActionType.SENTRY_APP,
185+
},
186+
"data": {"settings": updated_sentry_app_settings},
187+
"type": Action.Type.SENTRY_APP,
188+
},
189+
],
190+
}
191+
]
192+
response = self.get_success_response(
193+
self.organization.slug, self.workflow.id, raw_data=self.valid_workflow
194+
)
195+
updated_workflow = Workflow.objects.get(id=response.data.get("id"))
196+
action_filter = WorkflowDataConditionGroup.objects.get(workflow=updated_workflow)
197+
dcga = DataConditionGroupAction.objects.get(condition_group=action_filter.condition_group)
198+
action = dcga.action
199+
200+
assert response.status_code == 200
201+
assert action.type == Action.Type.SENTRY_APP
202+
assert action.config == {
203+
"sentry_app_identifier": SentryAppIdentifier.SENTRY_APP_ID,
204+
"target_identifier": str(self.sentry_app.id),
205+
"target_type": ActionTarget.SENTRY_APP.value,
206+
}
207+
assert action.data["settings"] == updated_sentry_app_settings
208+
76209
def test_update_triggers_with_empty_conditions(self) -> None:
77210
"""Test that passing an empty list to triggers.conditions clears all conditions"""
78211
# Create a workflow with a trigger condition

0 commit comments

Comments
 (0)