diff --git a/src/sentry/notifications/apps.py b/src/sentry/notifications/apps.py index 731ba25d3a3e..b96c5a01f5be 100644 --- a/src/sentry/notifications/apps.py +++ b/src/sentry/notifications/apps.py @@ -7,7 +7,13 @@ class Config(AppConfig): def ready(self) -> None: # Imports to populate registries import sentry.notifications.platform.discord.provider # noqa: F401 + import sentry.notifications.platform.discord.renderers.issue # noqa: F401 + import sentry.notifications.platform.discord.renderers.metric_alert # noqa: F401 import sentry.notifications.platform.email.provider # noqa: F401 import sentry.notifications.platform.msteams.provider # noqa: F401 import sentry.notifications.platform.slack.provider # noqa: F401 + import sentry.notifications.platform.slack.renderers.issue # noqa: F401 + import sentry.notifications.platform.slack.renderers.metric_alert # noqa: F401 + import sentry.notifications.platform.slack.renderers.seer # noqa: F401 + import sentry.notifications.platform.slack.renderers.seer_agent_write_approval # noqa: F401 import sentry.notifications.platform.templates # noqa: F401 diff --git a/src/sentry/notifications/platform/api/endpoints/internal_registered_templates.py b/src/sentry/notifications/platform/api/endpoints/internal_registered_templates.py index c461787cbae7..8e22f07efe59 100644 --- a/src/sentry/notifications/platform/api/endpoints/internal_registered_templates.py +++ b/src/sentry/notifications/platform/api/endpoints/internal_registered_templates.py @@ -104,7 +104,7 @@ def serialize_slack_preview[T: NotificationData]( ) -> dict[str, Any]: data = template.example_data rendered_template = template.render_example() - renderer = SlackNotificationProvider.get_renderer(data=data, category=template.category) + renderer = SlackNotificationProvider.get_renderer(data=data) message = renderer.render(data=data, rendered_template=rendered_template) serialized_blocks = [] diff --git a/src/sentry/notifications/platform/discord/provider.py b/src/sentry/notifications/platform/discord/provider.py index a9e29683ef3b..b92e23fa7a56 100644 --- a/src/sentry/notifications/platform/discord/provider.py +++ b/src/sentry/notifications/platform/discord/provider.py @@ -18,7 +18,6 @@ from sentry.notifications.platform.threading import ThreadContext from sentry.notifications.platform.types import ( LinkTextBlock, - NotificationCategory, NotificationData, NotificationProviderKey, NotificationRenderedTemplate, @@ -40,8 +39,6 @@ class DiscordRenderer(NotificationRenderer[DiscordRenderable]): - provider_key = NotificationProviderKey.DISCORD - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate @@ -152,21 +149,6 @@ def is_available(cls, *, organization: RpcOrganizationSummary | None = None) -> # TODO(ecosystem): Check for the integration, maybe a feature as well return False - @classmethod - def get_renderer( - cls, *, data: NotificationData, category: NotificationCategory - ) -> type[NotificationRenderer[DiscordRenderable]]: - from sentry.notifications.platform.discord.renderers.issue import IssueDiscordRenderer - from sentry.notifications.platform.discord.renderers.metric_alert import ( - DiscordMetricAlertRenderer, - ) - - if category == NotificationCategory.ISSUE: - return IssueDiscordRenderer - if category == NotificationCategory.METRIC_ALERT: - return DiscordMetricAlertRenderer - return cls.default_renderer - @classmethod def send( cls, diff --git a/src/sentry/notifications/platform/discord/renderers/issue.py b/src/sentry/notifications/platform/discord/renderers/issue.py index 855f2a163cec..e1dfe0a31aa7 100644 --- a/src/sentry/notifications/platform/discord/renderers/issue.py +++ b/src/sentry/notifications/platform/discord/renderers/issue.py @@ -1,9 +1,9 @@ from __future__ import annotations from sentry import eventstore -from sentry.integrations.discord.message_builder.issues import DiscordIssuesMessageBuilder from sentry.models.group import Group from sentry.notifications.platform.discord.provider import DiscordRenderable +from sentry.notifications.platform.registry import renderer_registry from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.service import NotificationRenderError from sentry.notifications.platform.templates.issue import IssueNotificationData @@ -11,13 +11,13 @@ NotificationData, NotificationProviderKey, NotificationRenderedTemplate, + NotificationSource, ) from sentry.services.eventstore.models import Event +@renderer_registry.register(NotificationProviderKey.DISCORD, sources=[NotificationSource.ISSUE]) class IssueDiscordRenderer(NotificationRenderer[DiscordRenderable]): - provider_key = NotificationProviderKey.DISCORD - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate @@ -25,6 +25,8 @@ def render[DataT: NotificationData]( if not isinstance(data, IssueNotificationData): raise ValueError(f"IssueDiscordRenderer does not support {data.__class__.__name__}") + from sentry.integrations.discord.message_builder.issues import DiscordIssuesMessageBuilder + # Retrieving Group and Event data is an anti-pattern, do not do this # in permanent renderers. try: diff --git a/src/sentry/notifications/platform/discord/renderers/metric_alert.py b/src/sentry/notifications/platform/discord/renderers/metric_alert.py index 4b6ab9127693..d2de41ad641a 100644 --- a/src/sentry/notifications/platform/discord/renderers/metric_alert.py +++ b/src/sentry/notifications/platform/discord/renderers/metric_alert.py @@ -1,25 +1,22 @@ from __future__ import annotations from sentry.incidents.models.incident import IncidentStatus -from sentry.integrations.discord.message_builder import INCIDENT_COLOR_MAPPING, LEVEL_TO_COLOR -from sentry.integrations.discord.message_builder.base.base import DiscordMessageBuilder -from sentry.integrations.discord.message_builder.base.embed.base import DiscordMessageEmbed -from sentry.integrations.discord.message_builder.base.embed.image import DiscordMessageEmbedImage -from sentry.integrations.discord.message_builder.metric_alerts import get_started_at -from sentry.integrations.metric_alerts import get_status_text from sentry.notifications.platform.discord.provider import DiscordRenderable +from sentry.notifications.platform.registry import renderer_registry from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData from sentry.notifications.platform.types import ( NotificationData, NotificationProviderKey, NotificationRenderedTemplate, + NotificationSource, ) +@renderer_registry.register( + NotificationProviderKey.DISCORD, sources=[NotificationSource.METRIC_ALERT] +) class DiscordMetricAlertRenderer(NotificationRenderer[DiscordRenderable]): - provider_key = NotificationProviderKey.DISCORD - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate @@ -29,6 +26,18 @@ def render[DataT: NotificationData]( f"DiscordMetricAlertRenderer does not support '{data.__class__.__name__}'. Provide a MetricAlertNotificationData instead." ) + from sentry.integrations.discord.message_builder import ( + INCIDENT_COLOR_MAPPING, + LEVEL_TO_COLOR, + ) + from sentry.integrations.discord.message_builder.base.base import DiscordMessageBuilder + from sentry.integrations.discord.message_builder.base.embed.base import DiscordMessageEmbed + from sentry.integrations.discord.message_builder.base.embed.image import ( + DiscordMessageEmbedImage, + ) + from sentry.integrations.discord.message_builder.metric_alerts import get_started_at + from sentry.integrations.metric_alerts import get_status_text + status = get_status_text(IncidentStatus(data.new_status)) description = f"{data.text}{get_started_at(data.open_period_context.date_started)}" color = LEVEL_TO_COLOR.get(INCIDENT_COLOR_MAPPING.get(status, "")) diff --git a/src/sentry/notifications/platform/email/provider.py b/src/sentry/notifications/platform/email/provider.py index 14db78280d64..0e108f7e9e1a 100644 --- a/src/sentry/notifications/platform/email/provider.py +++ b/src/sentry/notifications/platform/email/provider.py @@ -38,8 +38,6 @@ class EmailRenderer(NotificationRenderer[EmailRenderable]): - provider_key = NotificationProviderKey.EMAIL - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate diff --git a/src/sentry/notifications/platform/msteams/provider.py b/src/sentry/notifications/platform/msteams/provider.py index e4949ec20b8b..6325e6716d4e 100644 --- a/src/sentry/notifications/platform/msteams/provider.py +++ b/src/sentry/notifications/platform/msteams/provider.py @@ -38,8 +38,6 @@ class MSTeamsRenderer(NotificationRenderer[MSTeamsRenderable]): - provider_key = NotificationProviderKey.MSTEAMS - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate diff --git a/src/sentry/notifications/platform/provider.py b/src/sentry/notifications/platform/provider.py index 6a1b15adb451..b5e5c3d7f183 100644 --- a/src/sentry/notifications/platform/provider.py +++ b/src/sentry/notifications/platform/provider.py @@ -8,7 +8,6 @@ from sentry.notifications.platform.target import IntegrationNotificationTarget from sentry.notifications.platform.threading import ThreadContext from sentry.notifications.platform.types import ( - NotificationCategory, NotificationData, NotificationProviderKey, NotificationTarget, @@ -106,6 +105,8 @@ class NotificationProvider[RenderableT](Protocol): """ key: NotificationProviderKey + renderer_key: NotificationProviderKey | None = None + """The key renderers are registered against, when it differs from `key`.""" default_renderer: type[NotificationRenderer[RenderableT]] target_class: type[NotificationTarget] target_resource_types: list[NotificationTargetResourceType] @@ -133,15 +134,19 @@ def validate_target(cls, *, target: NotificationTarget) -> None: return @classmethod - def get_renderer( - cls, *, data: NotificationData, category: NotificationCategory - ) -> type[NotificationRenderer[RenderableT]]: + def get_renderer(cls, *, data: NotificationData) -> type[NotificationRenderer[RenderableT]]: """ Returns an instance of a renderer for a given notification, falling back to the default renderer. Override this to method to permit different renderers for the provider, though keep in mind that this may produce inconsistencies between notifications. """ - return cls.default_renderer + # Imported here since the registry imports this module to type its registrations. + from sentry.notifications.platform.registry import renderer_registry + + renderer = renderer_registry.get( + provider_key=cls.renderer_key or cls.key, source=data.source + ) + return renderer or cls.default_renderer @classmethod def is_available(cls, *, organization: RpcOrganizationSummary | None = None) -> bool: diff --git a/src/sentry/notifications/platform/registry.py b/src/sentry/notifications/platform/registry.py index 84d313d4293f..426adb419b9f 100644 --- a/src/sentry/notifications/platform/registry.py +++ b/src/sentry/notifications/platform/registry.py @@ -1,11 +1,17 @@ from __future__ import annotations +from collections.abc import Callable, Sequence from typing import Any from sentry.notifications.platform.provider import NotificationProvider -from sentry.notifications.platform.types import NotificationTemplate +from sentry.notifications.platform.renderer import NotificationRenderer +from sentry.notifications.platform.types import ( + NotificationProviderKey, + NotificationSource, + NotificationTemplate, +) from sentry.organizations.services.organization.model import RpcOrganizationSummary -from sentry.utils.registry import Registry +from sentry.utils.registry import AlreadyRegisteredError, Registry class NotificationProviderRegistry(Registry[type[NotificationProvider[Any]]]): @@ -33,5 +39,49 @@ def get_available( ] +class NotificationRendererRegistry: + """ + A registry for renderers which override a provider's default renderer. Renderers are keyed by + the provider they produce output for, and the notification sources they render. + """ + + def __init__(self) -> None: + self.registrations: dict[ + tuple[NotificationProviderKey, NotificationSource], type[NotificationRenderer[Any]] + ] = {} + + def register[RenderableT]( + self, provider_key: NotificationProviderKey, sources: Sequence[NotificationSource] + ) -> Callable[ + [type[NotificationRenderer[RenderableT]]], type[NotificationRenderer[RenderableT]] + ]: + if not sources: + raise ValueError("At least one notification source is required to register a renderer") + + def inner( + renderer: type[NotificationRenderer[RenderableT]], + ) -> type[NotificationRenderer[RenderableT]]: + for source in sources: + key = (provider_key, source) + if key in self.registrations: + raise AlreadyRegisteredError( + f"A registration already exists for {key}: {self.registrations[key]}" + ) + self.registrations[key] = renderer + return renderer + + return inner + + def get( + self, *, provider_key: NotificationProviderKey, source: NotificationSource + ) -> type[NotificationRenderer[Any]] | None: + """ + Returns the registered renderer for the provider/source pair, or `None` if the provider has + no override and should fall back to its default renderer. + """ + return self.registrations.get((provider_key, source)) + + provider_registry = NotificationProviderRegistry() template_registry = Registry[type[NotificationTemplate[Any]]]() +renderer_registry = NotificationRendererRegistry() diff --git a/src/sentry/notifications/platform/renderer.py b/src/sentry/notifications/platform/renderer.py index 9cbea90090bd..b84a890420ea 100644 --- a/src/sentry/notifications/platform/renderer.py +++ b/src/sentry/notifications/platform/renderer.py @@ -2,7 +2,6 @@ from sentry.notifications.platform.types import ( NotificationData, - NotificationProviderKey, NotificationRenderedTemplate, ) @@ -17,8 +16,6 @@ class NotificationRenderer[RenderableT](Protocol): RenderableT is a type that matches the connected provider. """ - provider_key: NotificationProviderKey - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate diff --git a/src/sentry/notifications/platform/service.py b/src/sentry/notifications/platform/service.py index bbe95c0931b1..72aa5d0715a4 100644 --- a/src/sentry/notifications/platform/service.py +++ b/src/sentry/notifications/platform/service.py @@ -171,7 +171,7 @@ def render_template[RenderableT]( provider: type[NotificationProvider[RenderableT]], ) -> RenderableT: rendered_template = template.render(data=data) - renderer = provider.get_renderer(data=data, category=template.category) + renderer = provider.get_renderer(data=data) return renderer.render(data=data, rendered_template=rendered_template) @staticmethod diff --git a/src/sentry/notifications/platform/slack/provider.py b/src/sentry/notifications/platform/slack/provider.py index d441c674c1c9..ad0663764708 100644 --- a/src/sentry/notifications/platform/slack/provider.py +++ b/src/sentry/notifications/platform/slack/provider.py @@ -32,13 +32,11 @@ from sentry.notifications.platform.threading import ThreadContext from sentry.notifications.platform.types import ( LinkTextBlock, - NotificationCategory, NotificationData, NotificationProviderKey, NotificationRenderedTemplate, NotificationSection, NotificationSectionType, - NotificationSource, NotificationTarget, NotificationTargetResourceType, NotificationTextBlock, @@ -65,8 +63,6 @@ class SlackRenderable(TypedDict): class SlackRenderer(NotificationRenderer[SlackRenderable]): - provider_key = NotificationProviderKey.SLACK - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate @@ -144,31 +140,6 @@ def is_available(cls, *, organization: RpcOrganizationSummary | None = None) -> # I currently view this as akin to a rollout or feature flag for the registry return False - @classmethod - def get_renderer( - cls, *, data: NotificationData, category: NotificationCategory - ) -> type[NotificationRenderer[SlackRenderable]]: - from sentry.notifications.platform.slack.renderers.issue import ( - IssueSlackRenderer, - ) - from sentry.notifications.platform.slack.renderers.metric_alert import ( - SlackMetricAlertRenderer, - ) - from sentry.notifications.platform.slack.renderers.seer import SeerSlackRenderer - from sentry.notifications.platform.slack.renderers.seer_agent_write_approval import ( - SeerAgentWriteApprovalSlackRenderer, - ) - - if category == NotificationCategory.SEER: - if data.source == NotificationSource.SEER_AGENT_WRITE_APPROVAL: - return SeerAgentWriteApprovalSlackRenderer - return SeerSlackRenderer - if category == NotificationCategory.ISSUE: - return IssueSlackRenderer - if category == NotificationCategory.METRIC_ALERT: - return SlackMetricAlertRenderer - return cls.default_renderer - @classmethod def send( cls, @@ -228,3 +199,4 @@ def _send_with_threading( @provider_registry.register(NotificationProviderKey.SLACK_STAGING) class SlackStagingNotificationProvider(SlackNotificationProvider): key = NotificationProviderKey.SLACK_STAGING + renderer_key = NotificationProviderKey.SLACK diff --git a/src/sentry/notifications/platform/slack/renderers/issue.py b/src/sentry/notifications/platform/slack/renderers/issue.py index 096fd3b73513..76642c29fb1f 100644 --- a/src/sentry/notifications/platform/slack/renderers/issue.py +++ b/src/sentry/notifications/platform/slack/renderers/issue.py @@ -2,6 +2,7 @@ from sentry import eventstore from sentry.models.group import Group +from sentry.notifications.platform.registry import renderer_registry from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.templates.issue import IssueNotificationData @@ -9,12 +10,12 @@ NotificationData, NotificationProviderKey, NotificationRenderedTemplate, + NotificationSource, ) +@renderer_registry.register(NotificationProviderKey.SLACK, sources=[NotificationSource.ISSUE]) class IssueSlackRenderer(NotificationRenderer[SlackRenderable]): - provider_key = NotificationProviderKey.SLACK - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate diff --git a/src/sentry/notifications/platform/slack/renderers/metric_alert.py b/src/sentry/notifications/platform/slack/renderers/metric_alert.py index 099a124d544e..7a03c22aef5c 100644 --- a/src/sentry/notifications/platform/slack/renderers/metric_alert.py +++ b/src/sentry/notifications/platform/slack/renderers/metric_alert.py @@ -1,12 +1,7 @@ from __future__ import annotations from sentry.incidents.models.incident import IncidentStatus -from sentry.integrations.messaging.types import LEVEL_TO_COLOR -from sentry.integrations.metric_alerts import get_status_text -from sentry.integrations.slack.message_builder.base.block import BlockSlackMessageBuilder -from sentry.integrations.slack.message_builder.incidents import get_started_at -from sentry.integrations.slack.message_builder.types import INCIDENT_COLOR_MAPPING -from sentry.integrations.slack.utils.escape import escape_slack_text +from sentry.notifications.platform.registry import renderer_registry from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData @@ -14,12 +9,14 @@ NotificationData, NotificationProviderKey, NotificationRenderedTemplate, + NotificationSource, ) +@renderer_registry.register( + NotificationProviderKey.SLACK, sources=[NotificationSource.METRIC_ALERT] +) class SlackMetricAlertRenderer(NotificationRenderer[SlackRenderable]): - provider_key = NotificationProviderKey.SLACK - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate @@ -27,6 +24,13 @@ def render[DataT: NotificationData]( if not isinstance(data, MetricAlertNotificationData): raise ValueError(f"SlackMetricAlertRenderer does not support {data.__class__.__name__}") + from sentry.integrations.messaging.types import LEVEL_TO_COLOR + from sentry.integrations.metric_alerts import get_status_text + from sentry.integrations.slack.message_builder.base.block import BlockSlackMessageBuilder + from sentry.integrations.slack.message_builder.incidents import get_started_at + from sentry.integrations.slack.message_builder.types import INCIDENT_COLOR_MAPPING + from sentry.integrations.slack.utils.escape import escape_slack_text + status = get_status_text(IncidentStatus(data.new_status)) incident_text = f"{data.text}\n{get_started_at(data.open_period_context.date_started)}" diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index 8a6b9c30fe2e..66c567d2653f 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -21,6 +21,7 @@ SectionBlock, ) +from sentry.notifications.platform.registry import renderer_registry from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.templates.seer import ( @@ -32,7 +33,9 @@ ) from sentry.notifications.platform.types import ( NotificationData, + NotificationProviderKey, NotificationRenderedTemplate, + NotificationSource, ) from sentry.seer.autofix.utils import AutofixStoppingPoint, CodingAgentProviderType @@ -85,6 +88,17 @@ class AutofixStageConfig(TypedDict): } +@renderer_registry.register( + NotificationProviderKey.SLACK, + sources=[ + NotificationSource.SEER_AUTOFIX_TRIGGER, + NotificationSource.SEER_AUTOFIX_ERROR, + NotificationSource.SEER_AUTOFIX_SUCCESS, + NotificationSource.SEER_AUTOFIX_UPDATE, + NotificationSource.SEER_AGENT_RESPONSE, + NotificationSource.SEER_AGENT_ERROR, + ], +) class SeerSlackRenderer(NotificationRenderer[SlackRenderable]): @classmethod def render[DataT: NotificationData]( diff --git a/src/sentry/notifications/platform/slack/renderers/seer_agent_write_approval.py b/src/sentry/notifications/platform/slack/renderers/seer_agent_write_approval.py index 9016e8ccc645..51dfddf0a172 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer_agent_write_approval.py +++ b/src/sentry/notifications/platform/slack/renderers/seer_agent_write_approval.py @@ -1,8 +1,7 @@ from django.conf import settings from slack_sdk.models.blocks import ActionsBlock, ButtonElement, MarkdownBlock -from sentry.integrations.slack.message_builder.routing import encode_action_id -from sentry.integrations.slack.message_builder.types import SlackAction +from sentry.notifications.platform.registry import renderer_registry from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.templates.seer import SeerAgentWriteApproval @@ -10,12 +9,14 @@ NotificationData, NotificationProviderKey, NotificationRenderedTemplate, + NotificationSource, ) +@renderer_registry.register( + NotificationProviderKey.SLACK, sources=[NotificationSource.SEER_AGENT_WRITE_APPROVAL] +) class SeerAgentWriteApprovalSlackRenderer(NotificationRenderer[SlackRenderable]): - provider_key = NotificationProviderKey.SLACK - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate @@ -25,6 +26,9 @@ def render[DataT: NotificationData]( f"SeerAgentWriteApprovalSlackRenderer does not support {data.__class__.__name__}" ) + from sentry.integrations.slack.message_builder.routing import encode_action_id + from sentry.integrations.slack.message_builder.types import SlackAction + scope_descriptions = { scope: description for scope_set in settings.SENTRY_SCOPE_SETS diff --git a/tests/sentry/integrations/slack/test_integration.py b/tests/sentry/integrations/slack/test_integration.py index df2e6e02e7b7..fb96a94f767c 100644 --- a/tests/sentry/integrations/slack/test_integration.py +++ b/tests/sentry/integrations/slack/test_integration.py @@ -21,7 +21,6 @@ from sentry.notifications.platform.slack.provider import SlackNotificationProvider from sentry.notifications.platform.target import IntegrationNotificationTarget from sentry.notifications.platform.types import ( - NotificationCategory, NotificationProviderKey, NotificationTargetResourceType, ) @@ -316,9 +315,7 @@ def setUp(self) -> None: ) data = MockNotification(message="test") rendered_template = MockNotificationTemplate().render(data) - renderer = SlackNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) + renderer = SlackNotificationProvider.get_renderer(data=data) self.slack_renderable = renderer.render(data=data, rendered_template=rendered_template) @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.chat_postMessage") diff --git a/tests/sentry/notifications/platform/discord/renderers/test_issue.py b/tests/sentry/notifications/platform/discord/renderers/test_issue.py index 0a54b939efdb..3fea480a9d8d 100644 --- a/tests/sentry/notifications/platform/discord/renderers/test_issue.py +++ b/tests/sentry/notifications/platform/discord/renderers/test_issue.py @@ -15,12 +15,12 @@ SerializableRuleProxy, ) from sentry.notifications.platform.types import ( - NotificationCategory, NotificationRenderedTemplate, NotificationSource, ) from sentry.services.eventstore.models import Event from sentry.testutils.cases import TestCase +from sentry.testutils.notifications.platform import MockNotification class IssueDiscordRendererTest(TestCase): @@ -133,22 +133,10 @@ def test_provider_returns_issue_renderer(self) -> None: id=1, label="Test Detector", data={}, project_id=self.project.id ), ) - renderer = DiscordNotificationProvider.get_renderer( - data=data, - category=NotificationCategory.ISSUE, - ) + renderer = DiscordNotificationProvider.get_renderer(data=data) assert renderer is IssueDiscordRenderer - def test_provider_returns_default_for_unknown_category(self) -> None: - data = IssueNotificationData( - group_id=self.group.id, - rule=SerializableRuleProxy( - id=1, label="Test Detector", data={}, project_id=self.project.id - ), - tags=["environment", "level"], - ) - renderer = DiscordNotificationProvider.get_renderer( - data=data, - category=NotificationCategory.DEBUG, - ) + def test_provider_returns_default_for_unregistered_source(self) -> None: + data = MockNotification(message="test") + renderer = DiscordNotificationProvider.get_renderer(data=data) assert renderer is DiscordNotificationProvider.default_renderer diff --git a/tests/sentry/notifications/platform/discord/renderers/test_metric_alert.py b/tests/sentry/notifications/platform/discord/renderers/test_metric_alert.py index 18c17d5d7557..a876041861ab 100644 --- a/tests/sentry/notifications/platform/discord/renderers/test_metric_alert.py +++ b/tests/sentry/notifications/platform/discord/renderers/test_metric_alert.py @@ -17,10 +17,10 @@ from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData from sentry.notifications.platform.templates.seer import SeerAutofixError from sentry.notifications.platform.types import ( - NotificationCategory, NotificationRenderedTemplate, ) from sentry.testutils.cases import TestCase +from sentry.testutils.notifications.platform import MockNotification from tests.sentry.notifications.notification_action.test_metric_alert_registry_handlers import ( MetricAlertHandlerBase, ) @@ -62,18 +62,12 @@ def test_render_raises_on_invalid_data_type(self) -> None: class DiscordMetricAlertProviderDispatchTest(TestCase): def test_provider_returns_metric_alert_renderer(self) -> None: data = _make_notification_data() - renderer = DiscordNotificationProvider.get_renderer( - data=data, - category=NotificationCategory.METRIC_ALERT, - ) + renderer = DiscordNotificationProvider.get_renderer(data=data) assert renderer is DiscordMetricAlertRenderer - def test_provider_returns_default_for_unknown_category(self) -> None: - data = _make_notification_data() - renderer = DiscordNotificationProvider.get_renderer( - data=data, - category=NotificationCategory.DEBUG, - ) + def test_provider_returns_default_for_unregistered_source(self) -> None: + data = MockNotification(message="test") + renderer = DiscordNotificationProvider.get_renderer(data=data) assert renderer is DiscordNotificationProvider.default_renderer diff --git a/tests/sentry/notifications/platform/discord/test_provider.py b/tests/sentry/notifications/platform/discord/test_provider.py index 843a4c8f96ec..94530f776675 100644 --- a/tests/sentry/notifications/platform/discord/test_provider.py +++ b/tests/sentry/notifications/platform/discord/test_provider.py @@ -15,10 +15,10 @@ from sentry.notifications.platform.discord.provider import ( DiscordNotificationProvider, DiscordRenderable, + DiscordRenderer, ) from sentry.notifications.platform.target import IntegrationNotificationTarget from sentry.notifications.platform.types import ( - NotificationCategory, NotificationProviderKey, NotificationRenderedAction, NotificationRenderedImage, @@ -55,11 +55,8 @@ def test_default_renderer(self) -> None: data = MockNotification(message="test") template = MockNotificationTemplate() rendered_template = template.render(data) - renderer = DiscordNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = DiscordRenderer.render(data=data, rendered_template=rendered_template) # Test basic structure assert "content" in renderable @@ -106,11 +103,8 @@ def test_renderer_without_chart(self) -> None: ) data = MockNotification(message="test without chart") - renderer = DiscordNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = DiscordRenderer.render(data=data, rendered_template=rendered_template) embed = renderable["embeds"][0] assert "image" not in embed or embed.get("image") is None @@ -130,11 +124,8 @@ def test_renderer_without_footer(self) -> None: ) data = MockNotification(message="test without footer") - renderer = DiscordNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = DiscordRenderer.render(data=data, rendered_template=rendered_template) embed = renderable["embeds"][0] assert "footer" not in embed or embed.get("footer") is None @@ -152,11 +143,8 @@ def test_renderer_without_actions(self) -> None: ) data = MockNotification(message="test without actions") - renderer = DiscordNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = DiscordRenderer.render(data=data, rendered_template=rendered_template) # Should have no components when no actions components = renderable["components"] @@ -182,11 +170,8 @@ def test_renderer_multiple_actions(self) -> None: ) data = MockNotification(message="test with multiple actions") - renderer = DiscordNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = DiscordRenderer.render(data=data, rendered_template=rendered_template) components = renderable["components"] assert len(components) == 1 diff --git a/tests/sentry/notifications/platform/msteams/test_provider.py b/tests/sentry/notifications/platform/msteams/test_provider.py index 784213098047..6719ae92f4b8 100644 --- a/tests/sentry/notifications/platform/msteams/test_provider.py +++ b/tests/sentry/notifications/platform/msteams/test_provider.py @@ -12,11 +12,11 @@ from sentry.notifications.platform.msteams.provider import ( MSTeamsNotificationProvider, MSTeamsRenderable, + MSTeamsRenderer, ) from sentry.notifications.platform.provider import SendFailure, SendFailureStatus from sentry.notifications.platform.target import IntegrationNotificationTarget from sentry.notifications.platform.types import ( - NotificationCategory, NotificationProviderKey, NotificationRenderedAction, NotificationRenderedTemplate, @@ -32,11 +32,8 @@ def test_default_renderer(self) -> None: data = MockNotification(message="test") template = MockNotificationTemplate() rendered_template = template.render(data) - renderer = MSTeamsNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = MSTeamsRenderer.render(data=data, rendered_template=rendered_template) # Verify the basic structure of the AdaptiveCard assert renderable["type"] == "AdaptiveCard" @@ -108,11 +105,8 @@ def test_renderer_without_chart(self) -> None: footer=base_template.footer, chart=None, # No chart ) - renderer = MSTeamsNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = MSTeamsRenderer.render(data=data, rendered_template=rendered_template) body_blocks = renderable["body"] assert len(body_blocks) == 6 # title, 3 body blocks, actions, footer (no chart) @@ -133,11 +127,8 @@ def test_renderer_without_footer(self) -> None: footer=None, # No footer chart=base_template.chart, ) - renderer = MSTeamsNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = MSTeamsRenderer.render(data=data, rendered_template=rendered_template) body_blocks = renderable["body"] assert len(body_blocks) == 6 # title, 3 body blocks, actions, chart (no footer) @@ -162,11 +153,8 @@ def test_renderer_without_actions(self) -> None: footer=base_template.footer, chart=base_template.chart, ) - renderer = MSTeamsNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = MSTeamsRenderer.render(data=data, rendered_template=rendered_template) body_blocks = renderable["body"] assert len(body_blocks) == 6 # title, 3 body blocks, chart, footer (no actions) @@ -200,11 +188,8 @@ def test_renderer_multiple_actions(self) -> None: footer=None, chart=None, ) - renderer = MSTeamsNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = MSTeamsRenderer.render(data=data, rendered_template=rendered_template) body_blocks = renderable["body"] actions_block = body_blocks[4] diff --git a/tests/sentry/notifications/platform/slack/renderers/test_issue.py b/tests/sentry/notifications/platform/slack/renderers/test_issue.py index 25c14fb3245f..70ea6a6e2b5a 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_issue.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_issue.py @@ -16,11 +16,11 @@ SerializableRuleProxy, ) from sentry.notifications.platform.types import ( - NotificationCategory, NotificationRenderedTemplate, NotificationSource, ) from sentry.testutils.cases import TestCase +from sentry.testutils.notifications.platform import MockNotification from sentry.utils import json from sentry.workflow_engine.models import Action from sentry.workflow_engine.types import ActionInvocation, WorkflowEventData @@ -335,21 +335,10 @@ def test_provider_returns_issue_renderer(self) -> None: id=1, label="Test Detector", data={}, project_id=self.project.id ), ) - renderer = SlackNotificationProvider.get_renderer( - data=data, - category=NotificationCategory.ISSUE, - ) + renderer = SlackNotificationProvider.get_renderer(data=data) assert renderer is IssueSlackRenderer - def test_provider_returns_default_for_unknown_category(self) -> None: - data = IssueNotificationData( - group_id=self.group.id, - rule=SerializableRuleProxy( - id=1, label="Test Detector", data={}, project_id=self.project.id - ), - ) - renderer = SlackNotificationProvider.get_renderer( - data=data, - category=NotificationCategory.DEBUG, - ) + def test_provider_returns_default_for_unregistered_source(self) -> None: + data = MockNotification(message="test") + renderer = SlackNotificationProvider.get_renderer(data=data) assert renderer is SlackNotificationProvider.default_renderer diff --git a/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py index 55647d670411..2113f3acf149 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py @@ -12,10 +12,10 @@ from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData from sentry.notifications.platform.templates.seer import SeerAutofixError from sentry.notifications.platform.types import ( - NotificationCategory, NotificationRenderedTemplate, ) from sentry.testutils.cases import TestCase +from sentry.testutils.notifications.platform import MockNotification from tests.sentry.notifications.notification_action.test_metric_alert_registry_handlers import ( MetricAlertHandlerBase, ) @@ -57,18 +57,12 @@ def test_render_raises_on_invalid_data_type(self) -> None: class SlackMetricAlertProviderDispatchTest(TestCase): def test_provider_returns_metric_alert_renderer(self) -> None: data = _make_notification_data() - renderer = SlackNotificationProvider.get_renderer( - data=data, - category=NotificationCategory.METRIC_ALERT, - ) + renderer = SlackNotificationProvider.get_renderer(data=data) assert renderer is SlackMetricAlertRenderer - def test_provider_returns_default_for_unknown_category(self) -> None: - data = _make_notification_data() - renderer = SlackNotificationProvider.get_renderer( - data=data, - category=NotificationCategory.DEBUG, - ) + def test_provider_returns_default_for_unregistered_source(self) -> None: + data = MockNotification(message="test") + renderer = SlackNotificationProvider.get_renderer(data=data) assert renderer is SlackNotificationProvider.default_renderer diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index 657ff15ce0ff..e8e9adc935c4 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -28,7 +28,7 @@ SeerAutofixPullRequest, SeerAutofixUpdate, ) -from sentry.notifications.platform.types import NotificationCategory, NotificationRenderedTemplate +from sentry.notifications.platform.types import NotificationRenderedTemplate from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.testutils.cases import TestCase @@ -409,9 +409,7 @@ def test_render_request_and_provider_dispatch(self) -> None: scopes=["org:write"], ) - renderer = SlackNotificationProvider.get_renderer( - data=data, category=NotificationCategory.SEER - ) + renderer = SlackNotificationProvider.get_renderer(data=data) assert renderer is SeerAgentWriteApprovalSlackRenderer renderable = renderer.render(data=data, rendered_template=self.rendered_template) diff --git a/tests/sentry/notifications/platform/slack/test_provider.py b/tests/sentry/notifications/platform/slack/test_provider.py index 3bc57a1f52c2..4596267f1cfc 100644 --- a/tests/sentry/notifications/platform/slack/test_provider.py +++ b/tests/sentry/notifications/platform/slack/test_provider.py @@ -1,3 +1,4 @@ +from datetime import datetime from unittest.mock import Mock, patch import pytest @@ -10,6 +11,8 @@ ) from slack_sdk.web import SlackResponse +from sentry.incidents.models.incident import IncidentStatus +from sentry.incidents.typings.metric_detector import OpenPeriodContext from sentry.integrations.types import IntegrationProviderSlug from sentry.notifications.models.notificationthread import NotificationThread from sentry.notifications.platform.provider import ( @@ -21,14 +24,17 @@ from sentry.notifications.platform.slack.provider import ( SlackNotificationProvider, SlackRenderable, + SlackRenderer, + SlackStagingNotificationProvider, ) +from sentry.notifications.platform.slack.renderers.metric_alert import SlackMetricAlertRenderer from sentry.notifications.platform.target import ( GenericNotificationTarget, IntegrationNotificationTarget, ) +from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData from sentry.notifications.platform.threading import ThreadContext, ThreadKey from sentry.notifications.platform.types import ( - NotificationCategory, NotificationProviderKey, NotificationSource, NotificationTargetResourceType, @@ -42,11 +48,8 @@ def test_default_renderer(self) -> None: data = MockNotification(message="test") template = MockNotificationTemplate() rendered_template = template.render(data) - renderer = SlackNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) - rendererable = renderer.render(data=data, rendered_template=rendered_template) + rendererable = SlackRenderer.render(data=data, rendered_template=rendered_template) rendererable_dict = [block.to_dict() for block in rendererable.get("blocks", [])] assert rendererable_dict == [ @@ -113,6 +116,24 @@ def test_is_available(self) -> None: assert SlackNotificationProvider.is_available() is False assert SlackNotificationProvider.is_available(organization=self.organization) is False + def test_staging_resolves_slack_renderers(self) -> None: + data = MetricAlertNotificationData( + group_id=1, + organization_id=self.organization.id, + notification_uuid="test-uuid", + action_id=1, + open_period_context=OpenPeriodContext(id=1, date_started=datetime(2024, 1, 1)), + new_status=IncidentStatus.CRITICAL.value, + title="Critical: Example Alert", + title_link="https://sentry.io", + text="123 events in the last 5 minutes", + ) + assert ( + SlackStagingNotificationProvider.get_renderer(data=data) + is SlackNotificationProvider.get_renderer(data=data) + is SlackMetricAlertRenderer + ) + class SlackNotificationProviderSendTest(TestCase): def setUp(self) -> None: diff --git a/tests/sentry/notifications/platform/test_provider.py b/tests/sentry/notifications/platform/test_provider.py index 3b2511f456ea..82474fc47c67 100644 --- a/tests/sentry/notifications/platform/test_provider.py +++ b/tests/sentry/notifications/platform/test_provider.py @@ -9,7 +9,6 @@ IntegrationNotificationTarget, ) from sentry.notifications.platform.types import ( - NotificationCategory, NotificationProviderKey, NotificationTargetResourceType, ) @@ -38,9 +37,7 @@ def test_all_registrants_follow_protocol(self) -> None: for resource_type in provider.target_resource_types: assert resource_type in NotificationTargetResourceType # Ensures the default renderer links back to its connected provider key - assert provider.default_renderer == provider.get_renderer( - data=self.data, category=NotificationCategory.DEBUG - ) + assert provider.default_renderer == provider.get_renderer(data=self.data) assert isinstance(provider.is_available(), bool) assert isinstance( provider.is_available( diff --git a/tests/sentry/notifications/test_apps.py b/tests/sentry/notifications/test_apps.py index 578d88721373..d6ee06825f96 100644 --- a/tests/sentry/notifications/test_apps.py +++ b/tests/sentry/notifications/test_apps.py @@ -1,5 +1,5 @@ from sentry.integrations.types import ExternalProviders -from sentry.notifications.platform.types import NotificationProviderKey +from sentry.notifications.platform.types import NotificationProviderKey, NotificationSource from sentry.testutils.cases import TestCase @@ -30,3 +30,24 @@ def test_registers_platform_providers(self) -> None: assert provider_registry.get(NotificationProviderKey.MSTEAMS) is not None assert provider_registry.get(NotificationProviderKey.SLACK) is not None assert provider_registry.get(NotificationProviderKey.SLACK_STAGING) is not None + + def test_registers_platform_renderers(self) -> None: + """ + If this test is failing for you, ensure the module holding your `@renderer_registry.register` + decorator is imported by `sentry.notifications.apps.Config.ready`. + """ + from sentry.notifications.platform.registry import renderer_registry + + assert set(renderer_registry.registrations) == { + (NotificationProviderKey.DISCORD, NotificationSource.ISSUE), + (NotificationProviderKey.DISCORD, NotificationSource.METRIC_ALERT), + (NotificationProviderKey.SLACK, NotificationSource.ISSUE), + (NotificationProviderKey.SLACK, NotificationSource.METRIC_ALERT), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AGENT_ERROR), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AGENT_RESPONSE), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AGENT_WRITE_APPROVAL), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AUTOFIX_ERROR), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AUTOFIX_SUCCESS), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AUTOFIX_TRIGGER), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AUTOFIX_UPDATE), + }