From 81113fd0aaca0bb7131aad88408695199731f5fe Mon Sep 17 00:00:00 2001 From: Gabe Villalobos Date: Mon, 13 Apr 2026 16:57:40 -0700 Subject: [PATCH 1/8] feat(np): Adds renderer for MSTeams issue alerts --- src/sentry/notifications/apps.py | 1 + .../platform/msteams/renderers/__init__.py | 0 .../platform/msteams/renderers/issue.py | 161 ++++++++++++++ .../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/apps.py b/src/sentry/notifications/apps.py index b96c5a01f5be..dc6dface5c81 100644 --- a/src/sentry/notifications/apps.py +++ b/src/sentry/notifications/apps.py @@ -11,6 +11,7 @@ def ready(self) -> None: 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.msteams.renderers.issue # 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 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/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 c39081290da808e62ea4c67b2531d7ad343861e2 Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Thu, 17 Sep 2026 13:44:14 -0700 Subject: [PATCH 2/8] 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 96186008b757b34426a98635a524412566e21176 Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Thu, 17 Sep 2026 16:01:04 -0700 Subject: [PATCH 3/8] 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) From 5ea23df8d1fa005f085163977eaa067b97fb5a6e Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Mon, 21 Sep 2026 15:39:23 -0700 Subject: [PATCH 4/8] test(np): Assert the MSTeams issue renderer is registered Co-authored-by: Cursor --- tests/sentry/notifications/test_apps.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/sentry/notifications/test_apps.py b/tests/sentry/notifications/test_apps.py index d6ee06825f96..f0f4c225d462 100644 --- a/tests/sentry/notifications/test_apps.py +++ b/tests/sentry/notifications/test_apps.py @@ -41,6 +41,7 @@ def test_registers_platform_renderers(self) -> None: assert set(renderer_registry.registrations) == { (NotificationProviderKey.DISCORD, NotificationSource.ISSUE), (NotificationProviderKey.DISCORD, NotificationSource.METRIC_ALERT), + (NotificationProviderKey.MSTEAMS, NotificationSource.ISSUE), (NotificationProviderKey.SLACK, NotificationSource.ISSUE), (NotificationProviderKey.SLACK, NotificationSource.METRIC_ALERT), (NotificationProviderKey.SLACK, NotificationSource.SEER_AGENT_ERROR), From 774fd4b9e3290f200051d7ce71f1289f1e09791a Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Mon, 21 Sep 2026 16:33:12 -0700 Subject: [PATCH 5/8] harden tests --- .../platform/msteams/renderers/test_issue.py | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/tests/sentry/notifications/platform/msteams/renderers/test_issue.py b/tests/sentry/notifications/platform/msteams/renderers/test_issue.py index 54ed40123cd2..28f979a8d23a 100644 --- a/tests/sentry/notifications/platform/msteams/renderers/test_issue.py +++ b/tests/sentry/notifications/platform/msteams/renderers/test_issue.py @@ -11,6 +11,7 @@ Action, ActionType, AdaptiveCard, + Block, ContentAlignment, OpenUrlAction, TextSize, @@ -24,6 +25,7 @@ ) from sentry.integrations.msteams.card_builder.utils import IssueConstants from sentry.models.group import Group +from sentry.models.groupassignee import GroupAssignee from sentry.models.project import Project from sentry.notifications.platform.msteams.provider import ( MSTeamsNotificationProvider, @@ -79,6 +81,8 @@ def _build_expected_card( group: Group, event: Any, notification_uuid: str = "test-uuid", + description: str | None = None, + assignee: str | None = None, ) -> AdaptiveCard: title_text = build_attachment_title(group) issue_url = group.get_absolute_url( @@ -125,11 +129,24 @@ def _build_expected_card( ), ) + fields: list[Block | None] = [] + if description: + fields.append( + create_text_block(description, size=TextSize.MEDIUM, weight=TextWeight.BOLDER) + ) + fields.append(footer) + if assignee: + fields.append( + create_text_block( + IssueConstants.ASSIGNEE_NOTE.format(assignee=assignee), size=TextSize.SMALL + ) + ) + actions: list[Action] = [ OpenUrlAction(type=ActionType.OPEN_URL, title="View Issue", url=issue_url) ] - return MSTeamsMessageBuilder().build(title=title, fields=[footer], actions=actions) + return MSTeamsMessageBuilder().build(title=title, fields=fields, actions=actions) def test_render_raises_on_invalid_data(self) -> None: from sentry.notifications.platform.templates.seer import SeerAutofixError @@ -170,6 +187,39 @@ def test_render_with_tags(self) -> None: # 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_with_description(self) -> None: + data, event, group = self._create_data( + event_data={ + "exception": { + "values": [{"type": "ValueError", "value": "something went wrong"}], + }, + }, + ) + 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, description="something went wrong" + ) + + def test_render_with_assignee(self) -> None: + data, event, group = self._create_data() + GroupAssignee.objects.assign(group, self.user) + 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, assignee=self.user.get_display_name() + ) + def test_render_group_not_found(self) -> None: data = IssueNotificationData( group_id=999999999, From d7f35d4be90c9548c55507f82a4b57d53ddcd77b Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Tue, 22 Sep 2026 10:56:19 -0700 Subject: [PATCH 6/8] ref(np): Import MSTeams card builders inside renderer methods `sentry.notifications.apps.Config.ready` imports this renderer, and importing any `sentry.integrations.msteams` submodule executes that package's `__init__`, which reaches `sentry.notifications.notification_action` and from there the Jira action validator. That module reads `system.url-prefix` at import time, which asserts because `ready()` runs during `django.setup()`, before `bind_cache_to_option_store()`. Matches the Discord and Slack renderers. Co-authored-by: Cursor --- .../platform/msteams/renderers/issue.py | 72 ++++++++++++------- 1 file changed, 46 insertions(+), 26 deletions(-) diff --git a/src/sentry/notifications/platform/msteams/renderers/issue.py b/src/sentry/notifications/platform/msteams/renderers/issue.py index bd6c05dae217..efa2ece03088 100644 --- a/src/sentry/notifications/platform/msteams/renderers/issue.py +++ b/src/sentry/notifications/platform/msteams/renderers/issue.py @@ -2,34 +2,9 @@ from collections.abc import Sequence from datetime import datetime +from typing import TYPE_CHECKING 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 @@ -47,6 +22,14 @@ ) from sentry.services.eventstore.models import Event, GroupEvent +if TYPE_CHECKING: + from sentry.integrations.msteams.card_builder.block import ( + Action, + Block, + ColumnSetBlock, + TextBlock, + ) + @renderer_registry.register(NotificationProviderKey.MSTEAMS, sources=[NotificationSource.ISSUE]) class IssueMSTeamsRenderer(NotificationRenderer[MSTeamsRenderable]): @@ -57,6 +40,8 @@ def render[DataT: NotificationData]( if not isinstance(data, IssueNotificationData): raise ValueError(f"IssueMSTeamsRenderer does not support {data.__class__.__name__}") + from sentry.integrations.msteams.card_builder.base import MSTeamsMessageBuilder + # Retrieving Group and Event data is an anti-pattern, do not do this # in permanent renderers. try: @@ -99,6 +84,13 @@ def build_issue_url(cls, *, group: Group, notification_uuid: str) -> str: @classmethod def build_title(cls, *, group: Group, issue_url: str) -> TextBlock: + from sentry.integrations.messaging.message_builder import build_attachment_title + from sentry.integrations.msteams.card_builder.block import ( + TextSize, + TextWeight, + create_text_block, + ) + title_text = build_attachment_title(group) return create_text_block( f"[{title_text}]({issue_url})", @@ -108,6 +100,13 @@ def build_title(cls, *, group: Group, issue_url: str) -> TextBlock: @classmethod def build_description(cls, group: Group) -> TextBlock | None: + from sentry.integrations.messaging.message_builder import build_attachment_text + from sentry.integrations.msteams.card_builder.block import ( + TextSize, + TextWeight, + create_text_block, + ) + text = build_attachment_text(group) if text: return create_text_block(text, size=TextSize.MEDIUM, weight=TextWeight.BOLDER) @@ -121,6 +120,21 @@ def build_footer( event: Event | GroupEvent | None, rules: Sequence[Rule], ) -> ColumnSetBlock: + from sentry.integrations.messaging.message_builder import build_footer + from sentry.integrations.msteams.card_builder import MSTEAMS_URL_FORMAT + from sentry.integrations.msteams.card_builder.block import ( + ContentAlignment, + 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 + 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 @@ -147,6 +161,10 @@ def build_footer( @classmethod def build_assignee_note(cls, group: Group) -> TextBlock | None: + from sentry.integrations.messaging.message_builder import format_actor_option_non_slack + from sentry.integrations.msteams.card_builder.block import TextSize, create_text_block + from sentry.integrations.msteams.card_builder.utils import IssueConstants + assignee = group.get_assignee() if assignee: assignee_text = format_actor_option_non_slack(assignee)["text"] @@ -158,4 +176,6 @@ def build_assignee_note(cls, group: Group) -> TextBlock | None: @classmethod def build_actions(cls, *, issue_url: str) -> list[Action]: + from sentry.integrations.msteams.card_builder.block import ActionType, OpenUrlAction + return [OpenUrlAction(type=ActionType.OPEN_URL, title="View Issue", url=issue_url)] From d494ad65f5d78ccf6e6eae9950d867b1b03f67ba Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Tue, 22 Sep 2026 14:01:50 -0700 Subject: [PATCH 7/8] also pass the event --- .../platform/msteams/renderers/issue.py | 8 +++-- .../platform/msteams/renderers/test_issue.py | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/sentry/notifications/platform/msteams/renderers/issue.py b/src/sentry/notifications/platform/msteams/renderers/issue.py index efa2ece03088..824e8b8ee9df 100644 --- a/src/sentry/notifications/platform/msteams/renderers/issue.py +++ b/src/sentry/notifications/platform/msteams/renderers/issue.py @@ -64,7 +64,7 @@ def render[DataT: NotificationData]( issue_url = cls.build_issue_url(group=group, notification_uuid=data.notification_uuid) fields: list[Block | None] = [ - cls.build_description(group), + cls.build_description(group=group, event=event), cls.build_footer(group=group, event=event, rules=rules), cls.build_assignee_note(group), ] @@ -99,7 +99,9 @@ def build_title(cls, *, group: Group, issue_url: str) -> TextBlock: ) @classmethod - def build_description(cls, group: Group) -> TextBlock | None: + def build_description( + cls, *, group: Group, event: Event | GroupEvent | None + ) -> TextBlock | None: from sentry.integrations.messaging.message_builder import build_attachment_text from sentry.integrations.msteams.card_builder.block import ( TextSize, @@ -107,7 +109,7 @@ def build_description(cls, group: Group) -> TextBlock | None: create_text_block, ) - text = build_attachment_text(group) + text = build_attachment_text(group, event) if text: return create_text_block(text, size=TextSize.MEDIUM, weight=TextWeight.BOLDER) return None diff --git a/tests/sentry/notifications/platform/msteams/renderers/test_issue.py b/tests/sentry/notifications/platform/msteams/renderers/test_issue.py index 28f979a8d23a..b8ebae2434fe 100644 --- a/tests/sentry/notifications/platform/msteams/renderers/test_issue.py +++ b/tests/sentry/notifications/platform/msteams/renderers/test_issue.py @@ -206,6 +206,35 @@ def test_render_with_description(self) -> None: group=group, event=event, description="something went wrong" ) + def test_render_uses_triggering_event_description(self) -> None: + data, _, group = self._create_data( + event_data={ + "exception": { + "values": [{"type": "ValueError", "value": "triggering event"}], + }, + "fingerprint": ["same-issue"], + }, + ) + latest_event = self.store_event( + data={ + "exception": { + "values": [{"type": "ValueError", "value": "latest event"}], + }, + "fingerprint": ["same-issue"], + }, + project_id=self.project.id, + ) + assert latest_event.group_id == group.id + + result = IssueMSTeamsRenderer.render( + data=data, + rendered_template=NotificationRenderedTemplate(subject="Issue Alert", body=[]), + ) + + assert result["body"][1] == create_text_block( + "triggering event", size=TextSize.MEDIUM, weight=TextWeight.BOLDER + ) + def test_render_with_assignee(self) -> None: data, event, group = self._create_data() GroupAssignee.objects.assign(group, self.user) From c51c820f59ac7327e6a23374f892698b557fc085 Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Tue, 22 Sep 2026 15:55:30 -0700 Subject: [PATCH 8/8] convert Event to GroupEvent, handle stale assignees as unassigned --- .../platform/msteams/renderers/issue.py | 8 +++- .../platform/msteams/renderers/test_issue.py | 40 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/sentry/notifications/platform/msteams/renderers/issue.py b/src/sentry/notifications/platform/msteams/renderers/issue.py index 824e8b8ee9df..37192e45fb98 100644 --- a/src/sentry/notifications/platform/msteams/renderers/issue.py +++ b/src/sentry/notifications/platform/msteams/renderers/issue.py @@ -21,6 +21,7 @@ NotificationSource, ) from sentry.services.eventstore.models import Event, GroupEvent +from sentry.types.actor import Actor if TYPE_CHECKING: from sentry.integrations.msteams.card_builder.block import ( @@ -57,6 +58,8 @@ def render[DataT: NotificationData]( event_id=data.event_id, group_id=data.group_id, ) + if isinstance(event, Event): + event = event.for_group(group) except Exception: raise NotificationRenderError(f"Failed to retrieve event {data.event_id}") @@ -167,7 +170,10 @@ def build_assignee_note(cls, group: Group) -> TextBlock | None: from sentry.integrations.msteams.card_builder.block import TextSize, create_text_block from sentry.integrations.msteams.card_builder.utils import IssueConstants - assignee = group.get_assignee() + try: + assignee = group.get_assignee() + except Actor.InvalidActor: + assignee = None if assignee: assignee_text = format_actor_option_non_slack(assignee)["text"] return create_text_block( diff --git a/tests/sentry/notifications/platform/msteams/renderers/test_issue.py b/tests/sentry/notifications/platform/msteams/renderers/test_issue.py index b8ebae2434fe..fab1d6b804bc 100644 --- a/tests/sentry/notifications/platform/msteams/renderers/test_issue.py +++ b/tests/sentry/notifications/platform/msteams/renderers/test_issue.py @@ -1,9 +1,11 @@ from __future__ import annotations from typing import Any +from unittest.mock import MagicMock, patch import pytest +from sentry import eventstore 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 @@ -24,6 +26,7 @@ create_text_block, ) from sentry.integrations.msteams.card_builder.utils import IssueConstants +from sentry.issues.issue_occurrence import IssueEvidence, IssueOccurrence from sentry.models.group import Group from sentry.models.groupassignee import GroupAssignee from sentry.models.project import Project @@ -42,6 +45,7 @@ ) from sentry.testutils.cases import TestCase from sentry.testutils.notifications.platform import MockNotification +from sentry.types.actor import Actor class IssueMSTeamsRendererTest(TestCase): @@ -235,6 +239,29 @@ def test_render_uses_triggering_event_description(self) -> None: "triggering event", size=TextSize.MEDIUM, weight=TextWeight.BOLDER ) + def test_render_with_generic_issue_description(self) -> None: + data, event, group = self._create_data() + group_event = event.for_group(group) + occurrence = MagicMock(spec=IssueOccurrence) + occurrence.important_evidence_display = IssueEvidence( + name="Evidence", value="generic issue description", important=True + ) + group_event.occurrence = occurrence + + with ( + patch.object(eventstore.backend, "get_event_by_id", return_value=event), + patch.object(event, "for_group", return_value=group_event) as for_group, + ): + result = IssueMSTeamsRenderer.render( + data=data, + rendered_template=NotificationRenderedTemplate(subject="Issue Alert", body=[]), + ) + + for_group.assert_called_once_with(group) + assert result["body"][1] == create_text_block( + "generic issue description", size=TextSize.MEDIUM, weight=TextWeight.BOLDER + ) + def test_render_with_assignee(self) -> None: data, event, group = self._create_data() GroupAssignee.objects.assign(group, self.user) @@ -249,6 +276,19 @@ def test_render_with_assignee(self) -> None: group=group, event=event, assignee=self.user.get_display_name() ) + def test_render_with_stale_assignee(self) -> None: + data, event, group = self._create_data() + + with patch.object( + Group, "get_assignee", side_effect=Actor.InvalidActor("Assignee no longer exists") + ): + result = IssueMSTeamsRenderer.render( + data=data, + rendered_template=NotificationRenderedTemplate(subject="Issue Alert", body=[]), + ) + + assert result == self._build_expected_card(group=group, event=event) + def test_render_group_not_found(self) -> None: data = IssueNotificationData( group_id=999999999,