Skip to content

Commit d9317fe

Browse files
iamrajjoshiandrewshie-sentry
authored andcommitted
♻️ feat(workflow_engine): issue alert action migration util for slack (#83123)
1 parent 0756bc9 commit d9317fe

File tree

6 files changed

+465
-0
lines changed

6 files changed

+465
-0
lines changed

Diff for: src/sentry/workflow_engine/migration_helpers/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import logging
2+
from typing import Any
3+
4+
from sentry.utils.registry import NoRegistrationExistsError
5+
from sentry.workflow_engine.models.action import Action
6+
from sentry.workflow_engine.typings.notification_action import (
7+
issue_alert_action_translator_registry,
8+
)
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
def build_notification_actions_from_rule_data_actions(
14+
actions: list[dict[str, Any]]
15+
) -> list[Action]:
16+
"""
17+
Builds notification actions from action field in Rule's data blob.
18+
Will only create actions that are valid, and log any errors before skipping the action.
19+
20+
:param actions: list of action data (Rule.data.actions)
21+
:return: list of notification actions (Action)
22+
"""
23+
24+
notification_actions: list[Action] = []
25+
26+
for action in actions:
27+
# Fetch the registry ID
28+
registry_id = action.get("id")
29+
if not registry_id:
30+
logger.error(
31+
"No registry ID found for action",
32+
extra={"action_uuid": action.get("uuid")},
33+
)
34+
continue
35+
36+
# Fetch the translator class
37+
try:
38+
translator_class = issue_alert_action_translator_registry.get(registry_id)
39+
translator = translator_class(action)
40+
except NoRegistrationExistsError:
41+
logger.exception(
42+
"Action translator not found for action",
43+
extra={
44+
"registry_id": registry_id,
45+
"action_uuid": action.get("uuid"),
46+
},
47+
)
48+
continue
49+
50+
# Check if the action is well-formed
51+
if not translator.is_valid():
52+
logger.error(
53+
"Action blob is malformed: missing required fields",
54+
extra={
55+
"registry_id": registry_id,
56+
"action_uuid": action.get("uuid"),
57+
"missing_fields": translator.missing_fields,
58+
},
59+
)
60+
continue
61+
62+
notification_action = Action(
63+
type=translator.action_type,
64+
data=translator.get_sanitized_data(),
65+
integration_id=translator.integration_id,
66+
target_identifier=translator.target_identifier,
67+
target_display=translator.target_display,
68+
target_type=translator.target_type,
69+
)
70+
71+
notification_actions.append(notification_action)
72+
73+
# Bulk create the actions
74+
Action.objects.bulk_create(notification_actions)
75+
76+
return notification_actions

Diff for: src/sentry/workflow_engine/typings/__init__.py

Whitespace-only changes.
+133
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import dataclasses
2+
from abc import ABC, abstractmethod
3+
from dataclasses import dataclass
4+
from typing import Any, ClassVar
5+
6+
from sentry.notifications.models.notificationaction import ActionTarget
7+
from sentry.utils.registry import Registry
8+
from sentry.workflow_engine.models.action import Action
9+
10+
# Keep existing excluded keys constant
11+
EXCLUDED_ACTION_DATA_KEYS = ["uuid", "id"]
12+
13+
14+
class BaseActionTranslator(ABC):
15+
action_type: ClassVar[Action.Type]
16+
registry_id: ClassVar[str]
17+
18+
def __init__(self, action: dict[str, Any]):
19+
self.action = action
20+
21+
@property
22+
@abstractmethod
23+
def required_fields(self) -> list[str]:
24+
"""Return the required fields for this action"""
25+
pass
26+
27+
@property
28+
def missing_fields(self) -> list[str]:
29+
"""Return the missing fields for this action"""
30+
return [field for field in self.required_fields if self.action.get(field) is None]
31+
32+
@property
33+
@abstractmethod
34+
def target_type(self) -> ActionTarget:
35+
"""Return the target type for this action"""
36+
pass
37+
38+
@property
39+
@abstractmethod
40+
def integration_id(self) -> Any | None:
41+
"""Return the integration ID for this action, if any"""
42+
pass
43+
44+
@property
45+
@abstractmethod
46+
def target_identifier(self) -> str | None:
47+
"""Return the target identifier for this action, if any"""
48+
pass
49+
50+
@property
51+
@abstractmethod
52+
def target_display(self) -> str | None:
53+
"""Return the display name for the target, if any"""
54+
pass
55+
56+
@property
57+
@abstractmethod
58+
def blob_type(self) -> type["DataBlob"] | None:
59+
"""Return the blob type for this action, if any"""
60+
pass
61+
62+
def is_valid(self) -> bool:
63+
"""
64+
Validate that all required fields for this action are present.
65+
Should be overridden by subclasses to add specific validation.
66+
"""
67+
return len(self.missing_fields) == 0
68+
69+
def get_sanitized_data(self) -> dict[str, Any]:
70+
"""
71+
Return sanitized data for this action
72+
If a blob type is specified, convert the action data to a dataclass
73+
Otherwise, remove excluded keys
74+
"""
75+
if self.blob_type:
76+
# Convert to dataclass if blob type is specified
77+
blob_instance = self.blob_type(
78+
**{k.name: self.action.get(k.name, "") for k in dataclasses.fields(self.blob_type)}
79+
)
80+
return dataclasses.asdict(blob_instance)
81+
else:
82+
# Remove excluded keys
83+
return {k: v for k, v in self.action.items() if k not in EXCLUDED_ACTION_DATA_KEYS}
84+
85+
86+
issue_alert_action_translator_registry = Registry[type[BaseActionTranslator]]()
87+
88+
89+
@issue_alert_action_translator_registry.register(
90+
"sentry.integrations.slack.notify_action.SlackNotifyServiceAction"
91+
)
92+
class SlackActionTranslator(BaseActionTranslator):
93+
action_type = Action.Type.SLACK
94+
registry_id = "sentry.integrations.slack.notify_action.SlackNotifyServiceAction"
95+
96+
@property
97+
def required_fields(self) -> list[str]:
98+
return ["channel_id", "workspace", "channel"]
99+
100+
@property
101+
def target_type(self) -> ActionTarget:
102+
return ActionTarget.SPECIFIC
103+
104+
@property
105+
def integration_id(self) -> Any | None:
106+
return self.action.get("workspace")
107+
108+
@property
109+
def target_identifier(self) -> str | None:
110+
return self.action.get("channel_id")
111+
112+
@property
113+
def target_display(self) -> str | None:
114+
return self.action.get("channel")
115+
116+
@property
117+
def blob_type(self) -> type["DataBlob"]:
118+
return SlackDataBlob
119+
120+
121+
@dataclass
122+
class DataBlob:
123+
"""DataBlob is a generic type that represents the data blob for a notification action."""
124+
125+
pass
126+
127+
128+
@dataclass
129+
class SlackDataBlob(DataBlob):
130+
"""SlackDataBlob is a specific type that represents the data blob for a Slack notification action."""
131+
132+
tags: str = ""
133+
notes: str = ""

Diff for: tests/sentry/workflow_engine/migration_helpers/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)