From b108eaff6787137928ec4d2e570e2429803c931b Mon Sep 17 00:00:00 2001 From: Gabe Villalobos Date: Mon, 13 Apr 2026 16:57:40 -0700 Subject: [PATCH 1/3] feat(np): Adds renderer for MSTeams issue alerts --- .../platform/msteams/renderers/__init__.py | 0 .../platform/msteams/renderers/issue.py | 161 ++++++++++++++ src/sentry/notifications/platform/registry.py | 1 + .../platform/msteams/renderers/__init__.py | 0 .../platform/msteams/renderers/test_issue.py | 209 ++++++++++++++++++ 5 files changed, 371 insertions(+) create mode 100644 src/sentry/notifications/platform/msteams/renderers/__init__.py create mode 100644 src/sentry/notifications/platform/msteams/renderers/issue.py create mode 100644 tests/sentry/notifications/platform/msteams/renderers/__init__.py create mode 100644 tests/sentry/notifications/platform/msteams/renderers/test_issue.py diff --git a/src/sentry/notifications/platform/msteams/renderers/__init__.py b/src/sentry/notifications/platform/msteams/renderers/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/sentry/notifications/platform/msteams/renderers/issue.py b/src/sentry/notifications/platform/msteams/renderers/issue.py new file mode 100644 index 000000000000..acfdd6b1374a --- /dev/null +++ b/src/sentry/notifications/platform/msteams/renderers/issue.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +from collections.abc import Sequence +from datetime import datetime + +from sentry import eventstore +from sentry.integrations.messaging.message_builder import ( + build_attachment_text, + build_attachment_title, + build_footer, + format_actor_option_non_slack, +) +from sentry.integrations.msteams.card_builder import MSTEAMS_URL_FORMAT +from sentry.integrations.msteams.card_builder.base import MSTeamsMessageBuilder +from sentry.integrations.msteams.card_builder.block import ( + Action, + ActionType, + Block, + ColumnSetBlock, + ContentAlignment, + OpenUrlAction, + TextBlock, + TextSize, + TextWeight, + create_column_block, + create_column_set_block, + create_footer_column_block, + create_footer_logo_block, + create_footer_text_block, + create_text_block, +) +from sentry.integrations.msteams.card_builder.utils import IssueConstants +from sentry.integrations.types import IntegrationProviderSlug +from sentry.models.group import Group +from sentry.models.project import Project +from sentry.models.rule import Rule +from sentry.notifications.platform.msteams.provider import MSTeamsRenderable +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 +from sentry.notifications.platform.types import ( + NotificationData, + NotificationProviderKey, + NotificationRenderedTemplate, + NotificationSource, +) +from sentry.services.eventstore.models import Event, GroupEvent + + +@renderer_registry.register(NotificationProviderKey.MSTEAMS, sources=[NotificationSource.ISSUE]) +class IssueMSTeamsRenderer(NotificationRenderer[MSTeamsRenderable]): + @classmethod + def render[DataT: NotificationData]( + cls, *, data: DataT, rendered_template: NotificationRenderedTemplate + ) -> MSTeamsRenderable: + if not isinstance(data, IssueNotificationData): + raise ValueError(f"IssueMSTeamsRenderer does not support {data.__class__.__name__}") + + # Retrieving Group and Event data is an anti-pattern, do not do this + # in permanent renderers. + try: + group = Group.objects.get_from_cache(id=data.group_id) + except Group.DoesNotExist: + raise NotificationRenderError(f"Group {data.group_id} not found") + + event = None + if data.event_id: + try: + event = eventstore.backend.get_event_by_id( + project_id=group.project.id, + event_id=data.event_id, + group_id=data.group_id, + ) + except Exception: + raise NotificationRenderError(f"Failed to retrieve event {data.event_id}") + + rules = [data.rule.to_rule()] if data.rule else [] + issue_url = cls.build_issue_url(group=group, notification_uuid=data.notification_uuid) + + fields: list[Block | None] = [ + cls.build_description(group), + cls.build_footer(group=group, event=event, rules=rules), + cls.build_assignee_note(group), + ] + + return MSTeamsMessageBuilder().build( + title=cls.build_title(group=group, issue_url=issue_url), + fields=fields, + actions=cls.build_actions(issue_url=issue_url), + ) + + @classmethod + def build_issue_url(cls, *, group: Group, notification_uuid: str) -> str: + params: dict[str, str] = {"referrer": IntegrationProviderSlug.MSTEAMS.value} + if notification_uuid: + params["notification_uuid"] = notification_uuid + return group.get_absolute_url(params=params) + + @classmethod + def build_title(cls, *, group: Group, issue_url: str) -> TextBlock: + title_text = build_attachment_title(group) + return create_text_block( + f"[{title_text}]({issue_url})", + size=TextSize.LARGE, + weight=TextWeight.BOLDER, + ) + + @classmethod + def build_description(cls, group: Group) -> TextBlock | None: + text = build_attachment_text(group) + if text: + return create_text_block(text, size=TextSize.MEDIUM, weight=TextWeight.BOLDER) + return None + + @classmethod + def build_footer( + cls, + *, + group: Group, + event: Event | GroupEvent | None, + rules: Sequence[Rule], + ) -> ColumnSetBlock: + project = Project.objects.get_from_cache(id=group.project_id) + footer_text = build_footer( + group=group, project=project, url_format=MSTEAMS_URL_FORMAT, rules=rules + ) + + ts: datetime = group.last_seen + date = max(ts, event.datetime) if event else ts + date_str = date.replace(microsecond=0).isoformat() + + return create_column_set_block( + create_column_block(create_footer_logo_block()), + create_footer_column_block(create_footer_text_block(footer_text)), + create_column_block( + create_text_block( + IssueConstants.DATE_FORMAT.format(date=date_str), + size=TextSize.SMALL, + weight=TextWeight.LIGHTER, + horizontalAlignment=ContentAlignment.CENTER, + wrap=False, + ), + verticalContentAlignment=ContentAlignment.CENTER, + ), + ) + + @classmethod + def build_assignee_note(cls, group: Group) -> TextBlock | None: + assignee = group.get_assignee() + if assignee: + assignee_text = format_actor_option_non_slack(assignee)["text"] + return create_text_block( + IssueConstants.ASSIGNEE_NOTE.format(assignee=assignee_text), + size=TextSize.SMALL, + ) + return None + + @classmethod + def build_actions(cls, *, group: Group, issue_url: str) -> list[Action]: + return [OpenUrlAction(type=ActionType.OPEN_URL, title="View Issue", url=issue_url)] diff --git a/src/sentry/notifications/platform/registry.py b/src/sentry/notifications/platform/registry.py index 25623cfd014c..27b7a583300b 100644 --- a/src/sentry/notifications/platform/registry.py +++ b/src/sentry/notifications/platform/registry.py @@ -63,6 +63,7 @@ def _load(self) -> None: import sentry.notifications.platform.discord.renderers.issue # noqa: F401 import sentry.notifications.platform.discord.renderers.metric_alert # noqa: F401 + import sentry.notifications.platform.msteams.renderers.issue # 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 diff --git a/tests/sentry/notifications/platform/msteams/renderers/__init__.py b/tests/sentry/notifications/platform/msteams/renderers/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/sentry/notifications/platform/msteams/renderers/test_issue.py b/tests/sentry/notifications/platform/msteams/renderers/test_issue.py new file mode 100644 index 000000000000..df30ee97f4d7 --- /dev/null +++ b/tests/sentry/notifications/platform/msteams/renderers/test_issue.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from sentry.integrations.messaging.message_builder import build_attachment_title, build_footer +from sentry.integrations.msteams.card_builder import MSTEAMS_URL_FORMAT +from sentry.integrations.msteams.card_builder.base import MSTeamsMessageBuilder +from sentry.integrations.msteams.card_builder.block import ( + ActionType, + ContentAlignment, + OpenUrlAction, + TextSize, + TextWeight, + create_column_block, + create_column_set_block, + create_footer_column_block, + create_footer_logo_block, + create_footer_text_block, + create_text_block, +) +from sentry.integrations.msteams.card_builder.utils import IssueConstants +from sentry.models.group import Group +from sentry.models.project import Project +from sentry.notifications.platform.msteams.provider import ( + MSTeamsNotificationProvider, +) +from sentry.notifications.platform.msteams.renderers.issue import IssueMSTeamsRenderer +from sentry.notifications.platform.service import NotificationRenderError +from sentry.notifications.platform.templates.issue import ( + IssueNotificationData, + SerializableRuleProxy, +) +from sentry.notifications.platform.types import ( + NotificationRenderedTemplate, + NotificationSource, +) +from sentry.testutils.cases import TestCase +from sentry.testutils.notifications.platform import MockNotification + + +class IssueMSTeamsRendererTest(TestCase): + def _create_data( + self, + *, + tags: list[str] | None = None, + event_data: dict[str, Any] | None = None, + ) -> tuple[IssueNotificationData, Any, Group]: + event = self.store_event( + data=event_data or {"message": "test event"}, + project_id=self.project.id, + ) + group = event.group + assert group is not None + + data = IssueNotificationData( + group_id=group.id, + event_id=event.event_id, + notification_uuid="test-uuid", + tags=tags, + rule=SerializableRuleProxy( + id=1, + label="Test Detector", + data={ + "actions": [{"workflow_id": 1}], + }, + project_id=self.project.id, + ), + ) + + return data, event, group + + def _build_expected_card( + self, + *, + group: Group, + event: Any, + notification_uuid: str = "test-uuid", + ) -> dict[str, Any]: + title_text = build_attachment_title(group) + issue_url = group.get_absolute_url( + params={"referrer": "msteams", "notification_uuid": notification_uuid} + ) + + title = create_text_block( + f"[{title_text}]({issue_url})", + size=TextSize.LARGE, + weight=TextWeight.BOLDER, + ) + + project = Project.objects.get_from_cache(id=group.project_id) + rules = [ + SerializableRuleProxy( + id=1, + label="Test Detector", + data={"actions": [{"workflow_id": 1}]}, + project_id=self.project.id, + ).to_rule() + ] + footer_text = build_footer( + group=group, project=project, url_format=MSTEAMS_URL_FORMAT, rules=rules + ) + + from datetime import datetime + + ts: datetime = group.last_seen + date = max(ts, event.datetime) if event else ts + date_str = date.replace(microsecond=0).isoformat() + + footer = create_column_set_block( + create_column_block(create_footer_logo_block()), + create_footer_column_block(create_footer_text_block(footer_text)), + create_column_block( + create_text_block( + IssueConstants.DATE_FORMAT.format(date=date_str), + size=TextSize.SMALL, + weight=TextWeight.LIGHTER, + horizontalAlignment=ContentAlignment.CENTER, + wrap=False, + ), + verticalContentAlignment=ContentAlignment.CENTER, + ), + ) + + actions = [OpenUrlAction(type=ActionType.OPEN_URL, title="View Issue", url=issue_url)] + + return MSTeamsMessageBuilder().build(title=title, fields=[footer], actions=actions) + + def test_render_raises_on_invalid_data(self) -> None: + from sentry.notifications.platform.templates.seer import SeerAutofixError + + invalid_data = SeerAutofixError(error_message="test") + rendered_template = NotificationRenderedTemplate(subject="test", body=[]) + + with pytest.raises(ValueError, match="does not support"): + IssueMSTeamsRenderer.render( + data=invalid_data, + rendered_template=rendered_template, + ) + + def test_render_produces_card(self) -> None: + data, event, group = self._create_data() + rendered_template = NotificationRenderedTemplate(subject="Issue Alert", body=[]) + + result = IssueMSTeamsRenderer.render( + data=data, + rendered_template=rendered_template, + ) + + assert result == self._build_expected_card(group=group, event=event) + + def test_render_with_tags(self) -> None: + data, event, group = self._create_data( + tags=["level"], + event_data={"message": "tagged event", "level": "error"}, + ) + rendered_template = NotificationRenderedTemplate(subject="Issue Alert", body=[]) + + result = IssueMSTeamsRenderer.render( + data=data, + rendered_template=rendered_template, + ) + + # Tags are not rendered in the MS Teams card (unlike Slack/Discord) + # since the card builder doesn't use them. The card should still render. + assert result == self._build_expected_card(group=group, event=event) + + def test_render_group_not_found(self) -> None: + data = IssueNotificationData( + group_id=999999999, + notification_uuid="test-uuid", + rule=SerializableRuleProxy( + id=1, label="Test Detector", data={}, project_id=self.project.id + ), + ) + rendered_template = NotificationRenderedTemplate(subject="Issue Alert", body=[]) + + with pytest.raises(NotificationRenderError, match="Group 999999999 not found"): + IssueMSTeamsRenderer.render( + data=data, + rendered_template=rendered_template, + ) + + def test_source(self) -> None: + data = IssueNotificationData( + group_id=self.group.id, + rule=SerializableRuleProxy( + id=1, label="Test Detector", data={}, project_id=self.project.id + ), + ) + assert data.source == NotificationSource.ISSUE + + +class IssueMSTeamsProviderDispatchTest(TestCase): + def test_provider_returns_issue_renderer(self) -> None: + data = IssueNotificationData( + group_id=self.group.id, + rule=SerializableRuleProxy( + id=1, label="Test Detector", data={}, project_id=self.project.id + ), + ) + renderer = MSTeamsNotificationProvider.get_renderer(data=data) + assert renderer is IssueMSTeamsRenderer + + def test_provider_returns_default_for_unregistered_source(self) -> None: + data = MockNotification(message="test") + renderer = MSTeamsNotificationProvider.get_renderer(data=data) + assert renderer is MSTeamsNotificationProvider.default_renderer From daeeba84330fdea537bbb6ba9869053de4700dbc Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Thu, 17 Sep 2026 13:44:14 -0700 Subject: [PATCH 2/3] remove unused parameter --- src/sentry/notifications/platform/msteams/renderers/issue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/notifications/platform/msteams/renderers/issue.py b/src/sentry/notifications/platform/msteams/renderers/issue.py index acfdd6b1374a..bd6c05dae217 100644 --- a/src/sentry/notifications/platform/msteams/renderers/issue.py +++ b/src/sentry/notifications/platform/msteams/renderers/issue.py @@ -157,5 +157,5 @@ def build_assignee_note(cls, group: Group) -> TextBlock | None: return None @classmethod - def build_actions(cls, *, group: Group, issue_url: str) -> list[Action]: + def build_actions(cls, *, issue_url: str) -> list[Action]: return [OpenUrlAction(type=ActionType.OPEN_URL, title="View Issue", url=issue_url)] From 9333505649dfc1f06ab92520f086231768172499 Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Thu, 17 Sep 2026 16:01:04 -0700 Subject: [PATCH 3/3] fix(np): Type the expected MSTeams issue card helper Annotates _build_expected_card as returning an AdaptiveCard and types its actions list, resolving mypy errors in the MSTeams issue renderer tests. Co-authored-by: Cursor --- .../platform/msteams/renderers/test_issue.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/sentry/notifications/platform/msteams/renderers/test_issue.py b/tests/sentry/notifications/platform/msteams/renderers/test_issue.py index df30ee97f4d7..54ed40123cd2 100644 --- a/tests/sentry/notifications/platform/msteams/renderers/test_issue.py +++ b/tests/sentry/notifications/platform/msteams/renderers/test_issue.py @@ -8,7 +8,9 @@ from sentry.integrations.msteams.card_builder import MSTEAMS_URL_FORMAT from sentry.integrations.msteams.card_builder.base import MSTeamsMessageBuilder from sentry.integrations.msteams.card_builder.block import ( + Action, ActionType, + AdaptiveCard, ContentAlignment, OpenUrlAction, TextSize, @@ -77,7 +79,7 @@ def _build_expected_card( group: Group, event: Any, notification_uuid: str = "test-uuid", - ) -> dict[str, Any]: + ) -> AdaptiveCard: title_text = build_attachment_title(group) issue_url = group.get_absolute_url( params={"referrer": "msteams", "notification_uuid": notification_uuid} @@ -123,7 +125,9 @@ def _build_expected_card( ), ) - actions = [OpenUrlAction(type=ActionType.OPEN_URL, title="View Issue", url=issue_url)] + actions: list[Action] = [ + OpenUrlAction(type=ActionType.OPEN_URL, title="View Issue", url=issue_url) + ] return MSTeamsMessageBuilder().build(title=title, fields=[footer], actions=actions)