Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
161 changes: 161 additions & 0 deletions src/sentry/notifications/platform/msteams/renderers/issue.py
Original file line number Diff line number Diff line change
@@ -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, *, issue_url: str) -> list[Action]:
return [OpenUrlAction(type=ActionType.OPEN_URL, title="View Issue", url=issue_url)]
1 change: 1 addition & 0 deletions src/sentry/notifications/platform/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Empty file.
213 changes: 213 additions & 0 deletions tests/sentry/notifications/platform/msteams/renderers/test_issue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
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 (
Action,
ActionType,
AdaptiveCard,
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",
) -> AdaptiveCard:
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: list[Action] = [
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
Loading