diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index 333daba1292aa8..7999f7d95ee976 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -1,6 +1,8 @@ +from __future__ import annotations + from collections import defaultdict from datetime import timedelta -from typing import Any, Mapping, MutableMapping, Optional, Sequence +from typing import Any, Mapping, MutableMapping, Sequence from uuid import uuid4 from django.db import IntegrityError, transaction @@ -60,9 +62,9 @@ def handle_discard( request: Request, - group_list: Sequence["Group"], - projects: Sequence["Project"], - user: "User", + group_list: Sequence[Group], + projects: Sequence[Project], + user: User, ) -> Response: for project in projects: if not features.has("projects:discard-groups", project, actor=user): @@ -98,9 +100,7 @@ def handle_discard( return Response(status=204) -def self_subscribe_and_assign_issue( - acting_user: Optional["User"], group: "Group" -) -> Optional["ActorTuple"]: +def self_subscribe_and_assign_issue(acting_user: User | None, group: Group) -> ActorTuple | None: # Used during issue resolution to assign to acting user # returns None if the user didn't elect to self assign on resolution # or the group is assigned already, otherwise returns Actor @@ -118,8 +118,8 @@ def self_subscribe_and_assign_issue( def get_current_release_version_of_group( - group: "Group", follows_semver: bool = False -) -> Optional[Release]: + group: Group, follows_semver: bool = False +) -> Release | None: """ Function that returns the latest release version associated with a Group, and by latest we mean either most recent (date) or latest in semver versioning scheme @@ -161,12 +161,12 @@ def get_current_release_version_of_group( def update_groups( request: Request, - group_ids: Sequence["Group"], - projects: Sequence["Project"], + group_ids: Sequence[Group], + projects: Sequence[Project], organization_id: int, - search_fn: SearchFunction, - user: Optional["User"] = None, - data: Optional[Mapping[str, Any]] = None, + search_fn: SearchFunction | None, + user: User | None = None, + data: Mapping[str, Any] | None = None, ) -> Response: # If `user` and `data` are passed as parameters then they should override # the values in `request`. @@ -202,7 +202,7 @@ def update_groups( acting_user = user if user.is_authenticated else None - if not group_ids: + if search_fn and not group_ids: try: cursor_result, _ = search_fn( { @@ -250,7 +250,7 @@ def update_groups( .order_by("-sort")[0] ) activity_type = Activity.SET_RESOLVED_IN_RELEASE - activity_data: MutableMapping[str, Optional[Any]] = { + activity_data: MutableMapping[str, Any | None] = { # no version yet "version": "" } diff --git a/src/sentry/integrations/slack/endpoints/action.py b/src/sentry/integrations/slack/endpoints/action.py index dfe95a101df342..88cd2cb5889550 100644 --- a/src/sentry/integrations/slack/endpoints/action.py +++ b/src/sentry/integrations/slack/endpoints/action.py @@ -1,4 +1,6 @@ -from typing import Any, Dict, Mapping +from __future__ import annotations + +from typing import Any, Mapping, MutableMapping from requests import post from rest_framework.request import Request @@ -9,17 +11,18 @@ from sentry.api.base import Endpoint from sentry.api.helpers.group_index import update_groups from sentry.integrations.slack.client import SlackClient -from sentry.integrations.slack.message_builder.issues import build_group_attachment from sentry.integrations.slack.requests.action import SlackActionRequest from sentry.integrations.slack.requests.base import SlackRequestError from sentry.integrations.slack.views.link_identity import build_linking_url from sentry.integrations.slack.views.unlink_identity import build_unlinking_url from sentry.models import Group, Identity, IdentityProvider, Integration, Project +from sentry.notifications.utils.actions import MessageAction from sentry.shared_integrations.exceptions import ApiError from sentry.utils import json from sentry.web.decorators import transaction_start from ..message_builder import SlackBody +from ..message_builder.issues import SlackIssuesMessageBuilder from ..utils import logger LINK_IDENTITY_MESSAGE = ( @@ -110,10 +113,9 @@ def api_error( return self.respond_ephemeral(text) def on_assign( - self, request: Request, identity: Identity, group: Group, action: Mapping[str, Any] + self, request: Request, identity: Identity, group: Group, action: MessageAction ) -> None: - assignee = action["selected_options"][0]["value"] - + assignee = action.selected_options[0]["value"] if assignee == "none": assignee = None @@ -125,13 +127,11 @@ def on_status( request: Request, identity: Identity, group: Group, - action: Mapping[str, Any], + action: MessageAction, data: Mapping[str, Any], integration: Integration, ) -> None: - status = action["value"] - - status_data = status.split(":", 1) + status_data = (action.value or "").split(":", 1) status = {"status": status_data[0]} resolve_type = status_data[-1] @@ -208,7 +208,7 @@ def is_message(self, data: Mapping[str, Any]) -> bool: @transaction_start("SlackActionEndpoint") def post(self, request: Request) -> Response: - logging_data: Dict[str, str] = {} + logging_data: MutableMapping[str, str] = {} try: slack_request = SlackActionRequest(request) @@ -219,8 +219,9 @@ def post(self, request: Request) -> Response: data = slack_request.data # Actions list may be empty when receiving a dialog response - action_list = data.get("actions", []) - action_option = action_list and action_list[0].get("value", "") + action_list_raw = data.get("actions", []) + action_list = [MessageAction(**action_data) for action_data in action_list_raw] + action_option = (action_list[0].value if len(action_list) else None) or "" # if a user is just clicking our auto response in the messages tab we just return a 200 if action_option == "sentry_docs_link_clicked": @@ -281,7 +282,10 @@ def post(self, request: Request) -> Response: # Handle status dialog submission if slack_request.type == "dialog_submission" and "resolve_type" in data["submission"]: # Masquerade a status action - action = {"name": "status", "value": data["submission"]["resolve_type"]} + action = MessageAction( + name="status", + value=data["submission"]["resolve_type"], + ) try: self.on_status(request, identity, group, action, data, integration) @@ -289,7 +293,9 @@ def post(self, request: Request) -> Response: return self.api_error(slack_request, group, identity, error, "status_dialog") group = Group.objects.get(id=group.id) - attachment = build_group_attachment(group, identity=identity, actions=[action]) + attachment = SlackIssuesMessageBuilder( + group, identity=identity, actions=[action] + ).build() body = self.construct_reply( attachment, is_message=slack_request.callback_data["is_message"] @@ -316,7 +322,7 @@ def post(self, request: Request) -> Response: action_type = None try: for action in action_list: - action_type = action["name"] + action_type = action.name if action_type == "status": self.on_status(request, identity, group, action, data, integration) @@ -334,7 +340,9 @@ def post(self, request: Request) -> Response: # Reload group as it may have been mutated by the action group = Group.objects.get(id=group.id) - attachment = build_group_attachment(group, identity=identity, actions=action_list) + attachment = SlackIssuesMessageBuilder( + group, identity=identity, actions=action_list + ).build() body = self.construct_reply(attachment, is_message=self.is_message(data)) return self.respond(body) diff --git a/src/sentry/integrations/slack/message_builder/__init__.py b/src/sentry/integrations/slack/message_builder/__init__.py index 8b885593663219..221e0e59fa4d24 100644 --- a/src/sentry/integrations/slack/message_builder/__init__.py +++ b/src/sentry/integrations/slack/message_builder/__init__.py @@ -3,7 +3,7 @@ # TODO(mgaeta): Continue fleshing out these types. SlackAttachment = Mapping[str, Any] SlackBlock = Mapping[str, Any] -SlackBody = Union[SlackAttachment, Mapping[str, Sequence[SlackBlock]]] +SlackBody = Union[SlackAttachment, Sequence[SlackAttachment], Mapping[str, Sequence[SlackBlock]]] # Attachment colors used for issues with no actions take. LEVEL_TO_COLOR = { diff --git a/src/sentry/integrations/slack/message_builder/base/base.py b/src/sentry/integrations/slack/message_builder/base/base.py index 5c1f59fd0367ea..2846f2717ee21d 100644 --- a/src/sentry/integrations/slack/message_builder/base/base.py +++ b/src/sentry/integrations/slack/message_builder/base/base.py @@ -1,12 +1,33 @@ +from __future__ import annotations + from abc import ABC -from typing import Any, Optional +from typing import Any, Mapping, MutableMapping, Sequence -from sentry.integrations.slack.message_builder import LEVEL_TO_COLOR, SlackAttachment, SlackBody +from sentry.integrations.slack.message_builder import LEVEL_TO_COLOR, SlackBody from sentry.integrations.slack.message_builder.base import AbstractMessageBuilder +from sentry.notifications.utils.actions import MessageAction from sentry.utils.assets import get_asset_url from sentry.utils.http import absolute_uri +def get_slack_button(action: MessageAction) -> Mapping[str, Any]: + kwargs: MutableMapping[str, Any] = { + "text": action.label or action.name, + "name": action.name, + "type": action.type, + } + for field in ("style", "url", "value"): + value = getattr(action, field, None) + if value: + kwargs[field] = value + + if action.type == "select": + kwargs["selected_options"] = action.selected_options or [] + kwargs["option_groups"] = action.option_groups or [] + + return kwargs + + class SlackMessageBuilder(AbstractMessageBuilder, ABC): def build(self) -> SlackBody: """Abstract `build` method that all inheritors must implement.""" @@ -15,18 +36,22 @@ def build(self) -> SlackBody: @staticmethod def _build( text: str, - title: Optional[str] = None, - footer: Optional[str] = None, - color: Optional[str] = None, + title: str | None = None, + title_link: str | None = None, + footer: str | None = None, + color: str | None = None, + actions: Sequence[MessageAction] | None = None, **kwargs: Any, - ) -> SlackAttachment: + ) -> SlackBody: """ Helper to DRY up Slack specific fields. :param string text: Body text. :param [string] title: Title text. + :param [string] title_link: Optional URL attached to the title. :param [string] footer: Footer text. :param [string] color: The key in the Slack palate table, NOT hex. Default: "info". + :param [list[MessageAction]] actions: List of actions displayed alongside the message. :param kwargs: Everything else. """ # If `footer` string is passed, automatically attach a `footer_icon`. @@ -38,6 +63,11 @@ def _build( if title: kwargs["title"] = title + if title_link: + kwargs["title_link"] = title_link + + if actions is not None: + kwargs["actions"] = [get_slack_button(action) for action in actions] return { "text": text, diff --git a/src/sentry/integrations/slack/message_builder/issues.py b/src/sentry/integrations/slack/message_builder/issues.py index 33d931387aea11..342393737565b6 100644 --- a/src/sentry/integrations/slack/message_builder/issues.py +++ b/src/sentry/integrations/slack/message_builder/issues.py @@ -1,16 +1,7 @@ +from __future__ import annotations + import re -from typing import ( - Any, - Callable, - List, - Mapping, - MutableMapping, - Optional, - Sequence, - Set, - Tuple, - Union, -) +from typing import Any, Callable, Mapping, Sequence from django.core.cache import cache @@ -31,6 +22,7 @@ ) from sentry.notifications.notifications.base import BaseNotification, ProjectNotification from sentry.notifications.notifications.rules import AlertRuleNotification +from sentry.notifications.utils.actions import MessageAction from sentry.utils import json from sentry.utils.dates import to_timestamp from sentry.utils.http import absolute_uri @@ -40,12 +32,12 @@ STATUSES = {"resolved": "resolved", "ignored": "ignored", "unresolved": "re-opened"} -def format_actor_options(actors: Sequence[Union["Team", "User"]]) -> Sequence[Mapping[str, str]]: +def format_actor_options(actors: Sequence[Team | User]) -> Sequence[Mapping[str, str]]: sort_func: Callable[[Mapping[str, str]], Any] = lambda actor: actor["text"] return sorted((format_actor_option(actor) for actor in actors), key=sort_func) -def format_actor_option(actor: Union["Team", "User"]) -> Mapping[str, str]: +def format_actor_option(actor: Team | User) -> Mapping[str, str]: if isinstance(actor, User): return {"text": actor.get_display_name(), "value": f"user:{actor.id}"} if isinstance(actor, Team): @@ -54,7 +46,7 @@ def format_actor_option(actor: Union["Team", "User"]) -> Mapping[str, str]: raise NotImplementedError -def build_attachment_title(obj: Union[Group, Event]) -> str: +def build_attachment_title(obj: Group | Event) -> str: ev_metadata = obj.get_event_metadata() ev_type = obj.get_event_type() @@ -72,7 +64,7 @@ def build_attachment_title(obj: Union[Group, Event]) -> str: return title_str -def build_attachment_text(group: Group, event: Optional[Event] = None) -> Optional[Any]: +def build_attachment_text(group: Group, event: Event | None = None) -> Any | None: # Group and Event both implement get_event_{type,metadata} obj = event if event is not None else group ev_metadata = obj.get_event_metadata() @@ -84,7 +76,7 @@ def build_attachment_text(group: Group, event: Optional[Event] = None) -> Option return None -def build_assigned_text(identity: Identity, assignee: str) -> Optional[str]: +def build_assigned_text(identity: Identity, assignee: str) -> str | None: actor = ActorTuple.from_actor_identifier(assignee) try: @@ -108,18 +100,22 @@ def build_assigned_text(identity: Identity, assignee: str) -> Optional[str]: return f"*Issue assigned to {assignee_text} by <@{identity.external_id}>*" -def build_action_text(identity: Identity, action: Mapping[str, Any]) -> Optional[str]: - if action["name"] == "assign": - return build_assigned_text(identity, action["selected_options"][0]["value"]) +def build_action_text(identity: Identity, action: MessageAction) -> str | None: + if action.name == "assign": + selected_options = action.selected_options or [] + if not len(selected_options): + return None + assignee = selected_options[0].get("value") + return build_assigned_text(identity, assignee) # Resolve actions have additional 'parameters' after ':' - status = action["value"].split(":", 1)[0] + status = STATUSES.get((action.value or "").split(":", 1)[0]) # Action has no valid action text, ignore - if status not in STATUSES: + if not status: return None - return f"*Issue {STATUSES[status]} by <@{identity.external_id}>*" + return f"*Issue {status} by <@{identity.external_id}>*" def build_rule_url(rule: Any, group: Group, project: Project) -> str: @@ -132,7 +128,7 @@ def build_rule_url(rule: Any, group: Group, project: Project) -> str: return url -def build_footer(group: Group, project: Project, rules: Optional[Sequence[Rule]] = None) -> str: +def build_footer(group: Group, project: Project, rules: Sequence[Rule] | None = None) -> str: footer = f"{group.qualified_short_id}" if rules: rule_url = build_rule_url(rules[0], group, project) @@ -145,8 +141,8 @@ def build_footer(group: Group, project: Project, rules: Optional[Sequence[Rule]] def build_tag_fields( - event_for_tags: Any, tags: Optional[Set[str]] = None -) -> Sequence[Mapping[str, Union[str, bool]]]: + event_for_tags: Any, tags: set[str] | None = None +) -> Sequence[Mapping[str, str | bool]]: fields = [] if tags: event_tags = event_for_tags.tags if event_for_tags else [] @@ -182,7 +178,7 @@ def get_option_groups(group: Group) -> Sequence[Mapping[str, Any]]: def has_releases(project: Project) -> bool: cache_key = f"has_releases:2:{project.id}" - has_releases_option: Optional[bool] = cache.get(cache_key) + has_releases_option: bool | None = cache.get(cache_key) if has_releases_option is None: has_releases_option = ReleaseProject.objects.filter(project_id=project.id).exists() if has_releases_option: @@ -195,7 +191,7 @@ def has_releases(project: Project) -> bool: def get_action_text( text: str, actions: Sequence[Any], - identity: Optional[Identity] = None, + identity: Identity | None = None, ) -> str: return ( text @@ -215,60 +211,67 @@ def build_actions( project: Project, text: str, color: str, - actions: Optional[Sequence[Any]] = None, - identity: Optional[Identity] = None, -) -> Tuple[Sequence[Any], str, str]: + actions: Sequence[MessageAction] | None = None, + identity: Identity | None = None, +) -> tuple[Sequence[MessageAction], str, str]: """Having actions means a button will be shown on the Slack message e.g. ignore, resolve, assign.""" if actions: text += get_action_text(text, actions, identity) return [], text, "_actioned_issue" - ASSIGN_BUTTON: MutableMapping[str, Any] = { - "name": "assign", - "text": "Select Assignee...", - "type": "select", - } - IGNORE_BUTTON = { - "name": "status", - "type": "button", - "text": "Ignore", - "value": "ignored", - } - RESOLVE_BUTTON = { - "name": "resolve_dialog", - "text": "Resolve...", - "type": "button", - "value": "resolve_dialog", - } + ignore_button = MessageAction( + name="status", + label="Ignore", + value="ignored", + ) + + resolve_button = MessageAction( + name="resolve_dialog", + label="Resolve...", + value="resolve_dialog", + ) status = group.get_status() if not has_releases(project): - RESOLVE_BUTTON.update({"name": "status", "text": "Resolve", "value": "resolved"}) + resolve_button = MessageAction( + name="status", + label="Resolve", + value="resolved", + ) if status == GroupStatus.RESOLVED: - RESOLVE_BUTTON.update({"name": "status", "text": "Unresolve", "value": "unresolved"}) + resolve_button = MessageAction( + name="status", + label="Unresolve", + value="unresolved", + ) if status == GroupStatus.IGNORED: - IGNORE_BUTTON.update({"text": "Stop Ignoring", "value": "unresolved"}) + ignore_button = MessageAction( + name="status", + label="Stop Ignoring", + value="unresolved", + ) assignee = group.get_assignee() - ASSIGN_BUTTON.update( - { - "selected_options": format_actor_options([assignee]) if assignee else [], - "option_groups": get_option_groups(group), - } + assign_button = MessageAction( + name="assign", + label="Select Assignee...", + type="select", + selected_options=format_actor_options([assignee]) if assignee else [], + option_groups=get_option_groups(group), ) - return [RESOLVE_BUTTON, IGNORE_BUTTON, ASSIGN_BUTTON], text, color + return [resolve_button, ignore_button, assign_button], text, color def get_title_link( group: Group, - event: Optional[Event], + event: Event | None, link_to_event: bool, issue_details: bool, - notification: Optional[BaseNotification], + notification: BaseNotification | None, ) -> str: if event and link_to_event: url = group.get_absolute_url(params={"referrer": "slack"}, event_id=event.event_id) @@ -285,17 +288,17 @@ def get_title_link( return url_str -def get_timestamp(group: Group, event: Optional[Event]) -> float: +def get_timestamp(group: Group, event: Event | None) -> float: ts = group.last_seen return to_timestamp(max(ts, event.datetime) if event else ts) -def get_color(event_for_tags: Optional[Event], notification: Optional[BaseNotification]) -> str: +def get_color(event_for_tags: Event | None, notification: BaseNotification | None) -> str: if notification: if not isinstance(notification, AlertRuleNotification): return "info" if event_for_tags: - color: Optional[str] = event_for_tags.get_tag("level") + color: str | None = event_for_tags.get_tag("level") if color and color in LEVEL_TO_COLOR.keys(): return color return "error" @@ -305,15 +308,15 @@ class SlackIssuesMessageBuilder(SlackMessageBuilder): def __init__( self, group: Group, - event: Optional[Event] = None, - tags: Optional[Set[str]] = None, - identity: Optional[Identity] = None, - actions: Optional[Sequence[Any]] = None, - rules: Optional[List[Rule]] = None, + event: Event | None = None, + tags: set[str] | None = None, + identity: Identity | None = None, + actions: Sequence[MessageAction] | None = None, + rules: list[Rule] | None = None, link_to_event: bool = False, issue_details: bool = False, - notification: Optional[ProjectNotification] = None, - recipient: Optional[Union["Team", "User"]] = None, + notification: ProjectNotification | None = None, + recipient: Team | User | None = None, ) -> None: super().__init__() self.group = group @@ -366,11 +369,11 @@ def build(self) -> SlackBody: def build_group_attachment( group: Group, - event: Optional[Event] = None, - tags: Optional[Set[str]] = None, - identity: Optional[Identity] = None, - actions: Optional[Sequence[Any]] = None, - rules: Optional[List[Rule]] = None, + event: Event | None = None, + tags: set[str] | None = None, + identity: Identity | None = None, + actions: Sequence[MessageAction] | None = None, + rules: list[Rule] | None = None, link_to_event: bool = False, issue_details: bool = False, ) -> SlackBody: diff --git a/src/sentry/integrations/slack/message_builder/notifications.py b/src/sentry/integrations/slack/message_builder/notifications.py index ed42ede676c9db..e8605ad1b72505 100644 --- a/src/sentry/integrations/slack/message_builder/notifications.py +++ b/src/sentry/integrations/slack/message_builder/notifications.py @@ -1,42 +1,20 @@ -from typing import Any, Dict, List, Mapping, Union +from __future__ import annotations + +from typing import Any, Mapping from sentry.integrations.slack.message_builder import SlackBody from sentry.integrations.slack.message_builder.base.base import SlackMessageBuilder -from sentry.integrations.slack.message_builder.issues import ( - SlackIssuesMessageBuilder, - build_attachment_title, - get_title_link, -) +from sentry.integrations.slack.message_builder.issues import SlackIssuesMessageBuilder from sentry.models import Team, User -from sentry.notifications.notifications.activity.new_processing_issues import ( - NewProcessingIssuesActivityNotification, -) -from sentry.notifications.notifications.activity.release import ReleaseActivityNotification from sentry.notifications.notifications.base import BaseNotification, ProjectNotification -from sentry.notifications.utils import get_release -from sentry.utils.http import absolute_uri - -from ..utils import build_notification_footer -def build_deploy_buttons(notification: ReleaseActivityNotification) -> List[Dict[str, str]]: - buttons = [] - if notification.release: - release = get_release(notification.activity, notification.project.organization) - if release: - for project in notification.release.projects.all(): - project_url = absolute_uri( - f"/organizations/{project.organization.slug}/releases/{release.version}/?project={project.id}&unselectedSeries=Healthy/" - ) - buttons.append( - { - "text": project.slug, - "name": project.slug, - "type": "button", - "url": project_url, - } - ) - return buttons +def get_message_builder(klass: str) -> type[SlackNotificationsMessageBuilder]: + """TODO(mgaeta): HACK to get around circular imports.""" + return { + "IssueNotificationMessageBuilder": IssueNotificationMessageBuilder, + "SlackNotificationsMessageBuilder": SlackNotificationsMessageBuilder, + }[klass] class SlackNotificationsMessageBuilder(SlackMessageBuilder): @@ -44,66 +22,41 @@ def __init__( self, notification: BaseNotification, context: Mapping[str, Any], - recipient: Union["Team", "User"], + recipient: Team | User, ) -> None: super().__init__() self.notification = notification self.context = context self.recipient = recipient + def build(self) -> SlackBody: + return self._build( + title=self.notification.build_attachment_title(), + title_link=self.notification.get_title_link(), + text=self.notification.get_message_description(), + footer=self.notification.build_notification_footer(self.recipient), + actions=self.notification.get_message_actions(), + ) + -class SlackProjectNotificationsMessageBuilder(SlackNotificationsMessageBuilder): +class IssueNotificationMessageBuilder(SlackNotificationsMessageBuilder): def __init__( self, notification: ProjectNotification, context: Mapping[str, Any], - recipient: Union["Team", "User"], + recipient: Team | User, ) -> None: super().__init__(notification, context, recipient) - # TODO: use generics here to do this self.notification: ProjectNotification = notification def build(self) -> SlackBody: group = getattr(self.notification, "group", None) - # TODO: refactor so we don't call SlackIssuesMessageBuilder through SlackProjectNotificationsMessageBuilder - if self.notification.is_message_issue_unfurl: - return SlackIssuesMessageBuilder( - group=group, - event=getattr(self.notification, "event", None), - tags=self.context.get("tags", None), - rules=getattr(self.notification, "rules", None), - issue_details=True, - notification=self.notification, - recipient=self.recipient, - ).build() - - if isinstance(self.notification, ReleaseActivityNotification): - return self._build( - text="", - actions=build_deploy_buttons(self.notification), - footer=build_notification_footer(self.notification, self.recipient), - ) - - if isinstance(self.notification, NewProcessingIssuesActivityNotification): - return self._build( - title=self.notification.get_title(), - text=self.notification.get_message_description(), - footer=build_notification_footer(self.notification, self.recipient), - ) - - return self._build( - title=build_attachment_title(group), - title_link=get_title_link(group, None, False, True, self.notification), - text=self.notification.get_message_description(), - footer=build_notification_footer(self.notification, self.recipient), - color="info", - ) - - -def build_notification_attachment( - notification: BaseNotification, - context: Mapping[str, Any], - recipient: Union["Team", "User"], -) -> SlackBody: - """@deprecated""" - return notification.build_slack_attachment(context, recipient) + return SlackIssuesMessageBuilder( + group=group, + event=getattr(self.notification, "event", None), + tags=self.context.get("tags", None), + rules=getattr(self.notification, "rules", None), + issue_details=True, + notification=self.notification, + recipient=self.recipient, + ).build() diff --git a/src/sentry/integrations/slack/message_builder/organization_requests.py b/src/sentry/integrations/slack/message_builder/organization_requests.py deleted file mode 100644 index 2ed349e56dc894..00000000000000 --- a/src/sentry/integrations/slack/message_builder/organization_requests.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import TYPE_CHECKING, Any, Mapping, Union - -from sentry.integrations.slack.message_builder import SlackBody -from sentry.integrations.slack.message_builder.notifications import SlackNotificationsMessageBuilder -from sentry.notifications.notifications.organization_request import OrganizationRequestNotification - -if TYPE_CHECKING: - from sentry.models import Team, User - - -class SlackOrganizationRequestMessageBuilder(SlackNotificationsMessageBuilder): - def __init__( - self, - notification: OrganizationRequestNotification, - context: Mapping[str, Any], - recipient: Union["Team", "User"], - ) -> None: - super().__init__(notification, context, recipient) - # TODO: use generics here to do this - self.notification: OrganizationRequestNotification = notification - - def build(self) -> SlackBody: - # may need to pass more args to _build and pass recipient to certain helper functions - return self._build( - title=self.notification.build_attachment_title(), - text=self.notification.get_message_description(), - footer=self.notification.build_notification_footer(self.recipient), - actions=self.notification.get_actions(), - color="info", - ) diff --git a/src/sentry/integrations/slack/notifications.py b/src/sentry/integrations/slack/notifications.py index 72883c0afc2fe9..541b5291a679aa 100644 --- a/src/sentry/integrations/slack/notifications.py +++ b/src/sentry/integrations/slack/notifications.py @@ -1,10 +1,13 @@ +from __future__ import annotations + import logging from collections import defaultdict -from typing import Any, Iterable, Mapping, MutableMapping, Optional, Union +from typing import Any, Iterable, Mapping, MutableMapping from sentry.integrations.notifications import NotifyBasicMixin -from sentry.integrations.slack.client import SlackClient # NOQA -from sentry.integrations.slack.message_builder.notifications import build_notification_attachment +from sentry.integrations.slack.client import SlackClient +from sentry.integrations.slack.message_builder import SlackBody +from sentry.integrations.slack.message_builder.notifications import get_message_builder from sentry.models import ExternalActor, Identity, Integration, Organization, Team, User from sentry.notifications.notifications.base import BaseNotification from sentry.notifications.notify import register_notification_provider @@ -35,9 +38,21 @@ def send_message(self, channel_id: str, message: str) -> None: return +def get_attachments( + notification: BaseNotification, + recipient: Team | User, + context: Mapping[str, Any], +) -> SlackBody: + klass = get_message_builder(notification.message_builder) + attachments = klass(notification, context, recipient).build() + if isinstance(attachments, dict): + return [attachments] + return attachments + + def get_context( notification: BaseNotification, - recipient: Union["Team", "User"], + recipient: Team | User, shared_context: Mapping[str, Any], extra_context: Mapping[str, Any], ) -> Mapping[str, Any]: @@ -49,8 +64,8 @@ def get_context( def get_channel_and_integration_by_user( - user: "User", organization: "Organization" -) -> Mapping[str, "Integration"]: + user: User, organization: Organization +) -> Mapping[str, Integration]: identities = Identity.objects.filter( idp__type=EXTERNAL_PROVIDERS[ExternalProviders.SLACK], @@ -80,8 +95,8 @@ def get_channel_and_integration_by_user( def get_channel_and_integration_by_team( - team: "Team", organization: "Organization" -) -> Mapping[str, "Integration"]: + team: Team, organization: Organization +) -> Mapping[str, Integration]: try: external_actor = ( ExternalActor.objects.filter( @@ -99,9 +114,9 @@ def get_channel_and_integration_by_team( def get_channel_and_token_by_recipient( - organization: "Organization", recipients: Iterable[Union["Team", "User"]] -) -> Mapping[Union["Team", "User"], Mapping[str, str]]: - output: MutableMapping[Union["Team", "User"], MutableMapping[str, str]] = defaultdict(dict) + organization: Organization, recipients: Iterable[Team | User] +) -> Mapping[Team | User, Mapping[str, str]]: + output: MutableMapping[Team | User, MutableMapping[str, str]] = defaultdict(dict) for recipient in recipients: channels_to_integrations = ( get_channel_and_integration_by_user(recipient, organization) @@ -129,9 +144,9 @@ def get_channel_and_token_by_recipient( @register_notification_provider(ExternalProviders.SLACK) def send_notification_as_slack( notification: BaseNotification, - recipients: Iterable[Union["Team", "User"]], + recipients: Iterable[Team | User], shared_context: Mapping[str, Any], - extra_context_by_user_id: Optional[Mapping[int, Mapping[str, Any]]], + extra_context_by_user_id: Mapping[int, Mapping[str, Any]] | None, ) -> None: """Send an "activity" or "alert rule" notification to a Slack user or team.""" client = SlackClient() @@ -149,7 +164,8 @@ def send_notification_as_slack( ) extra_context = (extra_context_by_user_id or {}).get(recipient.id, {}) context = get_context(notification, recipient, shared_context, extra_context) - attachment = [build_notification_attachment(notification, context, recipient)] + attachments = get_attachments(notification, recipient, context) + for channel, token in tokens_by_channel.items(): # unfurl_links and unfurl_media are needed to preserve the intended message format # and prevent the app from replying with help text to the unfurl @@ -160,7 +176,7 @@ def send_notification_as_slack( "unfurl_links": False, "unfurl_media": False, "text": notification.get_notification_title(), - "attachments": json.dumps(attachment), + "attachments": json.dumps(attachments), } try: client.post("/chat.postMessage", data=payload, timeout=5) diff --git a/src/sentry/integrations/slack/utils/notifications.py b/src/sentry/integrations/slack/utils/notifications.py index 88de95425ea5dc..03e77a66b5976b 100644 --- a/src/sentry/integrations/slack/utils/notifications.py +++ b/src/sentry/integrations/slack/utils/notifications.py @@ -1,5 +1,7 @@ +from __future__ import annotations + import re -from typing import Mapping, Union +from typing import Mapping from urllib.parse import urljoin from sentry.constants import ObjectStatus @@ -48,7 +50,7 @@ def send_incident_alert_notification( logger.info("rule.fail.slack_post", extra={"error": str(e)}) -def get_referrer_qstring(notification: BaseNotification, recipient: Union["Team", "User"]) -> str: +def get_referrer_qstring(notification: BaseNotification, recipient: Team | User) -> str: # TODO: make a generic version that works for other notification types return ( "?referrer=" @@ -57,16 +59,14 @@ def get_referrer_qstring(notification: BaseNotification, recipient: Union["Team" ) -def get_settings_url(notification: BaseNotification, recipient: Union["Team", "User"]) -> str: +def get_settings_url(notification: BaseNotification, recipient: Team | User) -> str: url_str = "/settings/account/notifications/" if notification.fine_tuning_key: url_str += f"{notification.fine_tuning_key}/" return str(urljoin(absolute_uri(url_str), get_referrer_qstring(notification, recipient))) -def build_notification_footer( - notification: ProjectNotification, recipient: Union["Team", "User"] -) -> str: +def build_notification_footer(notification: ProjectNotification, recipient: Team | User) -> str: if isinstance(recipient, Team): team = Team.objects.get(id=recipient.id) url_str = f"/settings/{notification.organization.slug}/teams/{team.slug}/notifications/" diff --git a/src/sentry/notifications/notifications/activity/base.py b/src/sentry/notifications/notifications/activity/base.py index 3e9c6b0627201c..c28289b08f1472 100644 --- a/src/sentry/notifications/notifications/activity/base.py +++ b/src/sentry/notifications/notifications/activity/base.py @@ -59,7 +59,7 @@ def get_context(self) -> MutableMapping[str, Any]: def get_participants_with_group_subscription_reason( self, - ) -> Mapping[ExternalProviders, Mapping[User, int]]: + ) -> Mapping[ExternalProviders, Mapping[Team | User, int]]: raise NotImplementedError def send(self) -> None: @@ -67,7 +67,7 @@ def send(self) -> None: class GroupActivityNotification(ActivityNotification, ABC): - is_message_issue_unfurl = True + message_builder = "IssueNotificationMessageBuilder" def __init__(self, activity: Activity) -> None: super().__init__(activity) @@ -88,7 +88,7 @@ def get_group_link(self) -> str: def get_participants_with_group_subscription_reason( self, - ) -> Mapping[ExternalProviders, Mapping[User, int]]: + ) -> Mapping[ExternalProviders, Mapping[Team | User, int]]: """This is overridden by the activity subclasses.""" return get_participants_for_group(self.group, self.activity.user) @@ -176,3 +176,13 @@ def description_as_html(self, description: str, params: Mapping[str, Any]) -> Sa context.update(params) return mark_safe(description.format(**context)) + + def get_title_link(self) -> str | None: + from sentry.integrations.slack.message_builder.issues import get_title_link + + return get_title_link(self.group, None, False, True, self) + + def build_attachment_title(self) -> str: + from sentry.integrations.slack.message_builder.issues import build_attachment_title + + return build_attachment_title(self.group) diff --git a/src/sentry/notifications/notifications/activity/new_processing_issues.py b/src/sentry/notifications/notifications/activity/new_processing_issues.py index 91f0117c725e1e..0834369bb84d6c 100644 --- a/src/sentry/notifications/notifications/activity/new_processing_issues.py +++ b/src/sentry/notifications/notifications/activity/new_processing_issues.py @@ -2,7 +2,7 @@ from typing import Any, MutableMapping -from sentry.models import Activity, Mapping, NotificationSetting, User +from sentry.models import Activity, Mapping, NotificationSetting, Team, User from sentry.notifications.types import GroupSubscriptionReason from sentry.notifications.utils import summarize_issues from sentry.types.integrations import ExternalProviders @@ -18,11 +18,16 @@ def __init__(self, activity: Activity) -> None: def get_participants_with_group_subscription_reason( self, - ) -> Mapping[ExternalProviders, Mapping[User, int]]: - users_by_provider = NotificationSetting.objects.get_notification_recipients(self.project) + ) -> Mapping[ExternalProviders, Mapping[Team | User, int]]: + participants_by_provider = NotificationSetting.objects.get_notification_recipients( + self.project + ) return { - provider: {user: GroupSubscriptionReason.processing_issue for user in users} - for provider, users in users_by_provider.items() + provider: { + participant: GroupSubscriptionReason.processing_issue + for participant in participants + } + for provider, participants in participants_by_provider.items() } def get_message_description(self) -> str: @@ -56,3 +61,9 @@ def get_notification_title(self) -> str: f"/settings/{self.organization.slug}/projects/{self.project.slug}/processing-issues/" ) return f"Processing issues on <{self.project.slug}|{project_url}" + + def build_attachment_title(self) -> str: + return self.get_subject() + + def get_title_link(self) -> str | None: + return None diff --git a/src/sentry/notifications/notifications/activity/note.py b/src/sentry/notifications/notifications/activity/note.py index a7834f26f8ef07..35f4208767ca65 100644 --- a/src/sentry/notifications/notifications/activity/note.py +++ b/src/sentry/notifications/notifications/activity/note.py @@ -6,7 +6,7 @@ class NoteActivityNotification(GroupActivityNotification): - is_message_issue_unfurl = False + message_builder = "SlackNotificationsMessageBuilder" def get_activity_name(self) -> str: return "Note" diff --git a/src/sentry/notifications/notifications/activity/release.py b/src/sentry/notifications/notifications/activity/release.py index 801dd240aa4ed1..22519b7c41842e 100644 --- a/src/sentry/notifications/notifications/activity/release.py +++ b/src/sentry/notifications/notifications/activity/release.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Iterable, Mapping, MutableMapping +from typing import Any, Iterable, Mapping, MutableMapping, Sequence from sentry_relay import parse_release @@ -16,6 +16,7 @@ get_users_by_emails, get_users_by_teams, ) +from sentry.notifications.utils.actions import MessageAction from sentry.notifications.utils.participants import get_participants_for_release from sentry.types.integrations import ExternalProviders from sentry.utils.compat import zip @@ -60,7 +61,7 @@ def should_email(self) -> bool: def get_participants_with_group_subscription_reason( self, - ) -> Mapping[ExternalProviders, Mapping[User, int]]: + ) -> Mapping[ExternalProviders, Mapping[Team | User, int]]: return get_participants_for_release(self.projects, self.organization, self.user_ids) def get_users_by_teams(self) -> Mapping[int, list[int]]: @@ -129,3 +130,24 @@ def get_filename(self) -> str: def get_category(self) -> str: return "release_activity_email" + + def get_message_actions(self) -> Sequence[MessageAction]: + if self.release: + release = get_release(self.activity, self.project.organization) + if release: + return [ + MessageAction( + name=project.slug, + url=absolute_uri( + f"/organizations/{project.organization.slug}/releases/{release.version}/?project={project.id}&unselectedSeries=Healthy/" + ), + ) + for project in self.release.projects.all() + ] + return [] + + def build_attachment_title(self) -> str: + return "" + + def get_title_link(self) -> str | None: + return None diff --git a/src/sentry/notifications/notifications/base.py b/src/sentry/notifications/notifications/base.py index 830e7b0c763527..f9bb669399c4da 100644 --- a/src/sentry/notifications/notifications/base.py +++ b/src/sentry/notifications/notifications/base.py @@ -1,54 +1,25 @@ from __future__ import annotations import abc -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Literal, Mapping, MutableMapping +from typing import TYPE_CHECKING, Any, Mapping, MutableMapping, Sequence from sentry import analytics +from sentry.notifications.utils.actions import MessageAction from sentry.types.integrations import ExternalProviders from sentry.utils.http import absolute_uri if TYPE_CHECKING: - - from sentry.integrations.slack.message_builder import SlackAttachment - from sentry.integrations.slack.message_builder.notifications import ( - SlackNotificationsMessageBuilder, - SlackProjectNotificationsMessageBuilder, - ) from sentry.models import Organization, Project, Team, User -@dataclass -class MessageAction: - label: str - url: str - style: Literal["primary", "danger", "default"] | None = None - - def as_slack(self) -> Mapping[str, Any]: - return { - "text": self.label, - "name": self.label, - "url": self.url, - "style": self.style, - "type": "button", - } - - class BaseNotification: + message_builder = "SlackNotificationsMessageBuilder" fine_tuning_key: str | None = None metrics_key: str = "" def __init__(self, organization: Organization): self.organization = organization - @property - def SlackMessageBuilderClass(self) -> type[SlackNotificationsMessageBuilder]: - from sentry.integrations.slack.message_builder.notifications import ( - SlackNotificationsMessageBuilder, - ) - - return SlackNotificationsMessageBuilder - @property def org_slug(self) -> str: return str(self.organization.slug) @@ -90,6 +61,15 @@ def get_recipient_context( def get_notification_title(self) -> str: raise NotImplementedError + def get_title_link(self) -> str | None: + raise NotImplementedError + + def build_attachment_title(self) -> str: + raise NotImplementedError + + def build_notification_footer(self, recipient: Team | User) -> str: + raise NotImplementedError + def get_message_description(self) -> Any: context = getattr(self, "context", None) return context["text_description"] if context else None @@ -100,11 +80,6 @@ def get_type(self) -> str: def get_unsubscribe_key(self) -> tuple[str, int, str | None] | None: return None - def build_slack_attachment( - self, context: Mapping[str, Any], recipient: Team | User - ) -> SlackAttachment: - return self.SlackMessageBuilderClass(self, context, recipient).build() - def record_notification_sent(self, recipient: Team | User, provider: ExternalProviders) -> None: raise NotImplementedError @@ -114,22 +89,15 @@ def get_log_params(self, recipient: Team | User) -> Mapping[str, Any]: "actor_id": recipient.actor_id, } + def get_message_actions(self) -> Sequence[MessageAction]: + return [] -class ProjectNotification(BaseNotification, abc.ABC): - is_message_issue_unfurl = False +class ProjectNotification(BaseNotification, abc.ABC): def __init__(self, project: Project) -> None: self.project = project super().__init__(project.organization) - @property - def SlackMessageBuilderClass(self) -> type[SlackProjectNotificationsMessageBuilder]: - from sentry.integrations.slack.message_builder.notifications import ( - SlackProjectNotificationsMessageBuilder, - ) - - return SlackProjectNotificationsMessageBuilder - def get_project_link(self) -> str: return str(absolute_uri(f"/{self.organization.slug}/{self.project.slug}/")) @@ -168,3 +136,8 @@ def get_subject_with_prefix(self, context: Mapping[str, Any] | None = None) -> b prefix = build_subject_prefix(self.project) return f"{prefix}{self.get_subject(context)}".encode() + + def build_notification_footer(self, recipient: Team | User) -> str: + from sentry.integrations.slack.utils.notifications import build_notification_footer + + return build_notification_footer(self, recipient) diff --git a/src/sentry/notifications/notifications/organization_request.py b/src/sentry/notifications/notifications/organization_request.py index 8099f4152b5407..9b376df36f5642 100644 --- a/src/sentry/notifications/notifications/organization_request.py +++ b/src/sentry/notifications/notifications/organization_request.py @@ -5,17 +5,14 @@ from typing import TYPE_CHECKING, Any, Iterable, Mapping, MutableMapping, Sequence from sentry import analytics, features, roles -from sentry.integrations.slack.utils.notifications import get_settings_url from sentry.models import NotificationSetting, OrganizationMember, Team -from sentry.notifications.notifications.base import BaseNotification, MessageAction +from sentry.notifications.notifications.base import BaseNotification from sentry.notifications.notify import notification_providers from sentry.notifications.types import NotificationSettingTypes -from sentry.types.integrations import ExternalProviders, get_provider_name +from sentry.notifications.utils.actions import MessageAction +from sentry.types.integrations import EXTERNAL_PROVIDERS, ExternalProviders if TYPE_CHECKING: - from sentry.integrations.slack.message_builder.organization_requests import ( - SlackOrganizationRequestMessageBuilder, - ) from sentry.models import Organization, User logger = logging.getLogger(__name__) @@ -30,14 +27,6 @@ def __init__(self, organization: Organization, requester: User) -> None: super().__init__(organization) self.requester = requester - @property - def SlackMessageBuilderClass(self) -> type[SlackOrganizationRequestMessageBuilder]: - from sentry.integrations.slack.message_builder.organization_requests import ( - SlackOrganizationRequestMessageBuilder, - ) - - return SlackOrganizationRequestMessageBuilder - def get_reference(self) -> Any: return self.organization @@ -46,7 +35,7 @@ def get_context(self) -> MutableMapping[str, Any]: def get_referrer(self, provider: ExternalProviders) -> str: # referrer needs the provider as well - return f"{self.referrer_base}-{get_provider_name(provider)}" + return f"{self.referrer_base}-{EXTERNAL_PROVIDERS[provider]}" def get_sentry_query_params(self, provider: ExternalProviders) -> str: return f"?referrer={self.get_referrer(provider)}" @@ -91,7 +80,7 @@ def send(self) -> None: context = self.get_context() for provider, recipients in participants_by_provider.items(): - # TODO: use safe_excute + # TODO: use safe_execute notify(provider, self, recipients, context) def get_member(self, user: User) -> OrganizationMember: @@ -118,9 +107,6 @@ def build_attachment_title(self) -> str: def get_message_description(self) -> str: raise NotImplementedError - def get_actions(self) -> Sequence[Mapping[str, str]]: - return [message_action.as_slack() for message_action in self.get_message_actions()] - def get_message_actions(self) -> Sequence[MessageAction]: raise NotImplementedError @@ -129,6 +115,8 @@ def get_role_string(self, member: OrganizationMember) -> str: return role_string def build_notification_footer(self, recipient: Team | User) -> str: + from sentry.integrations.slack.utils.notifications import get_settings_url + # not implemented for teams if isinstance(recipient, Team): raise NotImplementedError @@ -149,3 +137,6 @@ def record_notification_sent(self, recipient: Team | User, provider: ExternalPro target_user_id=recipient.id, providers=provider, ) + + def get_title_link(self) -> str | None: + return None diff --git a/src/sentry/notifications/notifications/rules.py b/src/sentry/notifications/notifications/rules.py index f8d0e199abefdb..3113343f6a907f 100644 --- a/src/sentry/notifications/notifications/rules.py +++ b/src/sentry/notifications/notifications/rules.py @@ -26,8 +26,8 @@ class AlertRuleNotification(ProjectNotification): + message_builder = "IssueNotificationMessageBuilder" fine_tuning_key = "alerts" - is_message_issue_unfurl = True metrics_key = "issue_alert" def __init__( diff --git a/src/sentry/notifications/notifications/user_report.py b/src/sentry/notifications/notifications/user_report.py index 744d5413f24ab9..7493a7c323eda4 100644 --- a/src/sentry/notifications/notifications/user_report.py +++ b/src/sentry/notifications/notifications/user_report.py @@ -26,7 +26,7 @@ def __init__(self, project: Project, report: Mapping[str, Any]) -> None: def get_participants_with_group_subscription_reason( self, - ) -> Mapping[ExternalProviders, Mapping[User, int]]: + ) -> Mapping[ExternalProviders, Mapping[Team | User, int]]: data_by_provider = GroupSubscription.objects.get_participants(group=self.group) return { provider: data diff --git a/src/sentry/notifications/utils/__init__.py b/src/sentry/notifications/utils/__init__.py index a03edfdd83644e..384de2e835d0e5 100644 --- a/src/sentry/notifications/utils/__init__.py +++ b/src/sentry/notifications/utils/__init__.py @@ -1,19 +1,8 @@ +from __future__ import annotations + import logging from collections import defaultdict -from typing import ( - TYPE_CHECKING, - Any, - Iterable, - List, - Mapping, - MutableMapping, - Optional, - Sequence, - Set, - Tuple, - Union, - cast, -) +from typing import TYPE_CHECKING, Any, Iterable, Mapping, MutableMapping, Sequence, cast from django.db.models import Count from django.utils.safestring import mark_safe @@ -54,7 +43,7 @@ logger = logging.getLogger(__name__) -def get_projects(projects: Iterable[Project], team_ids: Iterable[int]) -> Set[Project]: +def get_projects(projects: Iterable[Project], team_ids: Iterable[int]) -> set[Project]: team_projects = set( ProjectTeam.objects.filter(team_id__in=team_ids) .values_list("project_id", flat=True) @@ -63,8 +52,8 @@ def get_projects(projects: Iterable[Project], team_ids: Iterable[int]) -> Set[Pr return {p for p in projects if p.id in team_projects} -def get_users_by_teams(organization: Organization) -> Mapping[int, List[int]]: - user_teams: MutableMapping[int, List[int]] = defaultdict(list) +def get_users_by_teams(organization: Organization) -> Mapping[int, list[int]]: + user_teams: MutableMapping[int, list[int]] = defaultdict(list) queryset = User.objects.filter( sentry_orgmember_set__organization_id=organization.id ).values_list("id", "sentry_orgmember_set__teams") @@ -73,14 +62,14 @@ def get_users_by_teams(organization: Organization) -> Mapping[int, List[int]]: return user_teams -def get_deploy(activity: Activity) -> Optional[Deploy]: +def get_deploy(activity: Activity) -> Deploy | None: try: return Deploy.objects.get(id=activity.data["deploy_id"]) except Deploy.DoesNotExist: return None -def get_release(activity: Activity, organization: Organization) -> Optional[Release]: +def get_release(activity: Activity, organization: Organization) -> Release | None: try: return Release.objects.get( organization_id=organization.id, version=activity.data["version"] @@ -123,7 +112,7 @@ def get_users_by_emails(emails: Iterable[str], organization: Organization) -> Ma def get_repos( commits: Iterable[Commit], users_by_email: Mapping[str, User], organization: Organization -) -> Iterable[Mapping[str, Union[str, Iterable[Tuple[Commit, Optional[User]]]]]]: +) -> Iterable[Mapping[str, str | Iterable[tuple[Commit, User | None]]]]: repos = { r_id: {"name": r_name, "commits": []} for r_id, r_name in Repository.objects.filter( @@ -138,7 +127,7 @@ def get_repos( return list(repos.values()) -def get_commits_for_release(release: Release) -> Set[Commit]: +def get_commits_for_release(release: Release) -> set[Commit]: return { rc.commit for rc in ReleaseCommit.objects.filter(release=release).select_related( @@ -147,7 +136,7 @@ def get_commits_for_release(release: Release) -> Set[Commit]: } -def get_environment_for_deploy(deploy: Optional[Deploy]) -> str: +def get_environment_for_deploy(deploy: Deploy | None) -> str: if deploy: environment = Environment.objects.get(id=deploy.environment_id) if environment and environment.name: @@ -173,7 +162,7 @@ def summarize_issues( return rv -def get_link(group: Group, environment: Optional[str]) -> str: +def get_link(group: Group, environment: str | None) -> str: query_params = {"referrer": "alert_email"} if environment: query_params["environment"] = environment @@ -181,23 +170,23 @@ def get_link(group: Group, environment: Optional[str]) -> str: def get_integration_link(organization: Organization, integration_slug: str) -> str: - return str( - absolute_uri( - f"/settings/{organization.slug}/integrations/{integration_slug}/?referrer=alert_email" - ) + # Explicitly typing to satisfy mypy. + integration_link: str = absolute_uri( + f"/settings/{organization.slug}/integrations/{integration_slug}/?referrer=alert_email" ) + return integration_link def get_rules( rules: Sequence[Rule], organization: Organization, project: Project -) -> Sequence[Tuple[str, str]]: +) -> Sequence[tuple[str, str]]: return [ (rule.label, f"/organizations/{organization.slug}/alerts/rules/{project.slug}/{rule.id}/") for rule in rules ] -def get_commits(project: Project, event: "Event") -> Sequence[Mapping[str, Any]]: +def get_commits(project: Project, event: Event) -> Sequence[Mapping[str, Any]]: # lets identify possibly suspect commits and owners commits: MutableMapping[int, Mapping[str, Any]] = {} try: @@ -248,7 +237,7 @@ def has_alert_integration(project: Project) -> bool: return any(plugin.get_plugin_type() == "notification" for plugin in project_plugins) -def get_interface_list(event: "Event") -> Sequence[Tuple[str, str, str]]: +def get_interface_list(event: Event) -> Sequence[tuple[str, str, str]]: interface_list = [] for interface in event.interfaces.values(): body = interface.to_email_html(event) @@ -259,9 +248,7 @@ def get_interface_list(event: "Event") -> Sequence[Tuple[str, str, str]]: return interface_list -def send_activity_notification( - notification: Union["ActivityNotification", "UserReportNotification"] -) -> None: +def send_activity_notification(notification: ActivityNotification | UserReportNotification) -> None: if not notification.should_email(): return diff --git a/src/sentry/notifications/utils/actions.py b/src/sentry/notifications/utils/actions.py new file mode 100644 index 00000000000000..f6ddddebbbef49 --- /dev/null +++ b/src/sentry/notifications/utils/actions.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping + +from typing_extensions import Literal + +from sentry.utils.types import Any, Sequence + + +@dataclass +class MessageAction: + name: str + + # Optional label. This falls back to name. + label: str | None = None + + type: Literal["button", "select"] = "button" + + # If this is a button type, a url is required. + url: str | None = None + + # If this is a select type, the selected value. + value: str | None = None + + style: Literal["primary", "danger", "default"] | None = None + + # TODO(mgaeta): Refactor this to be provider-agnostic + selected_options: Sequence[Mapping[str, Any]] | None = None + option_groups: Sequence[Mapping[str, Any]] | None = None diff --git a/src/sentry/notifications/utils/participants.py b/src/sentry/notifications/utils/participants.py index 1a6c303cf3a116..b98d4016d73ad7 100644 --- a/src/sentry/notifications/utils/participants.py +++ b/src/sentry/notifications/utils/participants.py @@ -70,12 +70,9 @@ def get_providers_from_which_to_remove_user( def get_participants_for_group( group: Group, user: User | None = None -) -> Mapping[ExternalProviders, Mapping[User, int]]: - # TODO(dcramer): not used yet today except by Release's - if not group: - return {} +) -> Mapping[ExternalProviders, Mapping[Team | User, int]]: participants_by_provider: MutableMapping[ - ExternalProviders, MutableMapping[User, int] + ExternalProviders, MutableMapping[Team | User, int] ] = GroupSubscription.objects.get_participants(group) if user: # Optionally remove the actor that created the activity from the recipients list. @@ -101,7 +98,7 @@ def get_reason( def get_participants_for_release( projects: Iterable[Project], organization: Organization, user_ids: set[int] -) -> Mapping[ExternalProviders, Mapping[User, int]]: +) -> Mapping[ExternalProviders, Mapping[Team | User, int]]: # Collect all users with verified emails on a team in the related projects. users = set(User.objects.get_team_members_with_verified_email_for_projects(projects)) @@ -123,7 +120,7 @@ def get_participants_for_release( # Map users to their setting value. Prioritize user/org specific, then # user default, then product default. users_to_reasons_by_provider: MutableMapping[ - ExternalProviders, MutableMapping[User, int] + ExternalProviders, MutableMapping[Team | User, int] ] = defaultdict(dict) for user in users: notification_settings_by_scope = notification_settings_by_recipient.get(user, {}) @@ -141,14 +138,12 @@ def get_participants_for_release( def split_participants_and_context( - participants_with_reasons: Mapping[User, int] -) -> tuple[set[User], Mapping[int, Mapping[str, Any]]]: - participants = set() - extra_context = {} - for user, reason in participants_with_reasons.items(): - participants.add(user) - extra_context[user.id] = {"reason": reason} - return participants, extra_context + participants_with_reasons: Mapping[Team | User, int] +) -> tuple[Iterable[Team | User], Mapping[int, Mapping[str, Any]]]: + return participants_with_reasons.keys(), { + participant.actor_id: {"reason": reason} + for participant, reason in participants_with_reasons.items() + } def get_owners(project: Project, event: Event | None = None) -> Iterable[Team | User]: @@ -243,7 +238,7 @@ def get_send_to( target_type: ActionTargetType, target_identifier: int | None = None, event: Event | None = None, -) -> Mapping[ExternalProviders, Iterable[Team | User]]: +) -> Mapping[ExternalProviders, set[Team | User]]: recipients = determine_eligible_recipients(project, target_type, target_identifier, event) return get_recipients_by_provider(project, recipients) @@ -305,9 +300,9 @@ def get_users_from_team_fall_back( def combine_recipients_by_provider( - teams_by_provider: Mapping[ExternalProviders, Iterable[Team | User]], - users_by_provider: Mapping[ExternalProviders, Iterable[Team | User]], -) -> Mapping[ExternalProviders, Iterable[Team | User]]: + teams_by_provider: Mapping[ExternalProviders, set[Team | User]], + users_by_provider: Mapping[ExternalProviders, set[Team | User]], +) -> Mapping[ExternalProviders, set[Team | User]]: """TODO(mgaeta): Make this more generic and move it to utils.""" recipients_by_provider = defaultdict(set) for provider, teams in teams_by_provider.items(): @@ -321,7 +316,7 @@ def combine_recipients_by_provider( def get_recipients_by_provider( project: Project, recipients: Iterable[Team | User] -) -> Mapping[ExternalProviders, Iterable[Team | User]]: +) -> Mapping[ExternalProviders, set[Team | User]]: """Get the lists of recipients that should receive an Issue Alert by ExternalProvider.""" teams, users = partition_recipients(recipients) diff --git a/tests/sentry/integrations/slack/endpoints/test_action.py b/tests/sentry/integrations/slack/endpoints/test_action.py index a24a809497bf65..aeef094e0aca56 100644 --- a/tests/sentry/integrations/slack/endpoints/test_action.py +++ b/tests/sentry/integrations/slack/endpoints/test_action.py @@ -405,7 +405,12 @@ def test_sentry_docs_link_clicked(self, check_signing_secret_mock): "team": {"id": "TXXXXXXX1", "domain": "example.com"}, "user": {"id": self.external_id, "domain": "example"}, "type": "block_actions", - "actions": [{"value": "sentry_docs_link_clicked"}], + "actions": [ + { + "name": "", + "value": "sentry_docs_link_clicked", + } + ], } payload = {"payload": json.dumps(payload)} diff --git a/tests/sentry/integrations/slack/test_message_builder.py b/tests/sentry/integrations/slack/test_message_builder.py index 8bfb7c0d007203..3ed4c6526f5a41 100644 --- a/tests/sentry/integrations/slack/test_message_builder.py +++ b/tests/sentry/integrations/slack/test_message_builder.py @@ -1,15 +1,83 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Mapping + from django.urls import reverse +from sentry.eventstore.models import Event from sentry.incidents.logic import CRITICAL_TRIGGER_LABEL from sentry.integrations.slack.message_builder import LEVEL_TO_COLOR from sentry.integrations.slack.message_builder.incidents import SlackIncidentsMessageBuilder from sentry.integrations.slack.message_builder.issues import SlackIssuesMessageBuilder +from sentry.models import Group, Team, User from sentry.testutils import TestCase from sentry.utils.assets import get_asset_url from sentry.utils.dates import to_timestamp from sentry.utils.http import absolute_uri +def build_test_message( + teams: set[Team], + users: set[User], + timestamp: datetime, + group: Group, + event: Event | None = None, + link_to_event: bool = False, +) -> Mapping[str, Any]: + project = group.project + + title = group.title + title_link = f"http://testserver/organizations/{project.organization.slug}/issues/{group.id}" + if event: + title = event.title + if link_to_event: + title_link += f"/events/{event.event_id}" + title_link += "/?referrer=slack" + + return { + "text": "", + "color": "#E03E2F", + "actions": [ + {"name": "status", "text": "Resolve", "type": "button", "value": "resolved"}, + {"name": "status", "text": "Ignore", "type": "button", "value": "ignored"}, + { + "option_groups": [ + { + "text": "Teams", + "options": [ + {"text": f"#{team.slug}", "value": f"team:{team.id}"} for team in teams + ], + }, + { + "text": "People", + "options": [ + { + "text": user.email, + "value": f"user:{user.id}", + } + for user in users + ], + }, + ], + "text": "Select Assignee...", + "selected_options": [], + "type": "select", + "name": "assign", + }, + ], + "mrkdwn_in": ["text"], + "title": title, + "fields": [], + "footer": f"{project.slug.upper()}-1", + "ts": to_timestamp(timestamp), + "title_link": title_link, + "callback_id": '{"issue":' + str(group.id) + "}", + "fallback": f"[{project.slug}] {title}", + "footer_icon": "http://testserver/_static/{version}/sentry/images/sentry-email-avatar.png", + } + + class BuildIncidentAttachmentTest(TestCase): def test_simple(self): logo_url = absolute_uri(get_asset_url("sentry", "images/sentry-email-avatar.png")) @@ -88,152 +156,35 @@ def test_metric_value(self): } def test_build_group_attachment(self): - self.user = self.create_user("foo@example.com") - self.org = self.create_organization(name="Rowdy Tiger", owner=None) - self.team = self.create_team(organization=self.org, name="Mariachi Band") - self.project = self.create_project( - organization=self.org, teams=[self.team], name="Bengal-Elephant-Giraffe-Tree-House" - ) - self.create_member(user=self.user, organization=self.org, role="owner", teams=[self.team]) group = self.create_group(project=self.project) - ts = group.last_seen - assert SlackIssuesMessageBuilder(group).build() == { - "text": "", - "color": "#E03E2F", - "actions": [ - {"name": "status", "text": "Resolve", "type": "button", "value": "resolved"}, - {"text": "Ignore", "type": "button", "name": "status", "value": "ignored"}, - { - "option_groups": [ - { - "text": "Teams", - "options": [ - { - "text": "#mariachi-band", - "value": "team:" + str(self.team.id), - } - ], - }, - { - "text": "People", - "options": [ - { - "text": "foo@example.com", - "value": "user:" + str(self.user.id), - } - ], - }, - ], - "text": "Select Assignee...", - "selected_options": [], - "type": "select", - "name": "assign", - }, - ], - "mrkdwn_in": ["text"], - "title": group.title, - "fields": [], - "footer": "BENGAL-ELEPHANT-GIRAFFE-TREE-HOUSE-1", - "ts": to_timestamp(ts), - "title_link": "http://testserver/organizations/rowdy-tiger/issues/" - + str(group.id) - + "/?referrer=slack", - "callback_id": '{"issue":' + str(group.id) + "}", - "fallback": f"[{self.project.slug}] {group.title}", - "footer_icon": "http://testserver/_static/{version}/sentry/images/sentry-email-avatar.png", - } + + assert SlackIssuesMessageBuilder(group).build() == build_test_message( + teams={self.team}, + users={self.user}, + timestamp=group.last_seen, + group=group, + ) + event = self.store_event(data={}, project_id=self.project.id) - ts = event.datetime - assert SlackIssuesMessageBuilder(group, event).build() == { - "color": "#E03E2F", - "text": "", - "actions": [ - {"name": "status", "text": "Resolve", "type": "button", "value": "resolved"}, - {"text": "Ignore", "type": "button", "name": "status", "value": "ignored"}, - { - "option_groups": [ - { - "text": "Teams", - "options": [ - { - "text": "#mariachi-band", - "value": "team:" + str(self.team.id), - } - ], - }, - { - "text": "People", - "options": [ - { - "text": "foo@example.com", - "value": "user:" + str(self.user.id), - } - ], - }, - ], - "text": "Select Assignee...", - "selected_options": [], - "type": "select", - "name": "assign", - }, - ], - "mrkdwn_in": ["text"], - "title": event.title, - "fields": [], - "footer": "BENGAL-ELEPHANT-GIRAFFE-TREE-HOUSE-1", - "ts": to_timestamp(ts), - "title_link": "http://testserver/organizations/rowdy-tiger/issues/" - + str(group.id) - + "/?referrer=slack", - "callback_id": '{"issue":' + str(group.id) + "}", - "fallback": f"[{self.project.slug}] {event.title}", - "footer_icon": "http://testserver/_static/{version}/sentry/images/sentry-email-avatar.png", - } - assert SlackIssuesMessageBuilder(group, event, link_to_event=True).build() == { - "color": "#E03E2F", - "text": "", - "actions": [ - {"name": "status", "text": "Resolve", "type": "button", "value": "resolved"}, - {"text": "Ignore", "type": "button", "name": "status", "value": "ignored"}, - { - "option_groups": [ - { - "text": "Teams", - "options": [ - { - "text": "#mariachi-band", - "value": "team:" + str(self.team.id), - } - ], - }, - { - "text": "People", - "options": [ - { - "text": "foo@example.com", - "value": "user:" + str(self.user.id), - } - ], - }, - ], - "text": "Select Assignee...", - "selected_options": [], - "type": "select", - "name": "assign", - }, - ], - "mrkdwn_in": ["text"], - "title": event.title, - "fields": [], - "footer": "BENGAL-ELEPHANT-GIRAFFE-TREE-HOUSE-1", - "ts": to_timestamp(ts), - "title_link": f"http://testserver/organizations/rowdy-tiger/issues/{group.id}/events/{event.event_id}/" - + "?referrer=slack", - "callback_id": '{"issue":' + str(group.id) + "}", - "fallback": f"[{self.project.slug}] {event.title}", - "footer_icon": "http://testserver/_static/{version}/sentry/images/sentry-email-avatar.png", - } + assert SlackIssuesMessageBuilder(group, event).build() == build_test_message( + teams={self.team}, + users={self.user}, + timestamp=event.datetime, + group=group, + event=event, + ) + + assert SlackIssuesMessageBuilder( + group, event, link_to_event=True + ).build() == build_test_message( + teams={self.team}, + users={self.user}, + timestamp=event.datetime, + group=group, + event=event, + link_to_event=True, + ) def test_build_group_attachment_issue_alert(self): issue_alert_group = self.create_group(project=self.project) diff --git a/tests/sentry/integrations/slack/test_unfurl.py b/tests/sentry/integrations/slack/test_unfurl.py index b7d71703b8c881..4be70b78d4324d 100644 --- a/tests/sentry/integrations/slack/test_unfurl.py +++ b/tests/sentry/integrations/slack/test_unfurl.py @@ -7,9 +7,9 @@ from sentry.charts.types import ChartType from sentry.discover.models import DiscoverSavedQuery from sentry.incidents.logic import CRITICAL_TRIGGER_LABEL -from sentry.integrations.slack.message_builder.discover import build_discover_attachment -from sentry.integrations.slack.message_builder.incidents import build_incident_attachment -from sentry.integrations.slack.message_builder.issues import build_group_attachment +from sentry.integrations.slack.message_builder.discover import SlackDiscoverMessageBuilder +from sentry.integrations.slack.message_builder.incidents import SlackIncidentsMessageBuilder +from sentry.integrations.slack.message_builder.issues import SlackIssuesMessageBuilder from sentry.integrations.slack.unfurl import LinkType, UnfurlableUrl, link_handlers, match_link from sentry.testutils import TestCase from sentry.testutils.helpers import install_slack @@ -68,8 +68,11 @@ def test_unfurl_issues(self): unfurls = link_handlers[LinkType.ISSUES].fn(self.request, self.integration, links) - assert unfurls[links[0].url] == build_group_attachment(self.group) - assert unfurls[links[1].url] == build_group_attachment(group2, event, link_to_event=True) + assert unfurls[links[0].url] == SlackIssuesMessageBuilder(self.group).build() + assert ( + unfurls[links[1].url] + == SlackIssuesMessageBuilder(group2, event, link_to_event=True).build() + ) def test_unfurl_incidents(self): alert_rule = self.create_alert_rule() @@ -91,7 +94,7 @@ def test_unfurl_incidents(self): ] unfurls = link_handlers[LinkType.INCIDENTS].fn(self.request, self.integration, links) - assert unfurls[links[0].url] == build_incident_attachment(action, incident) + assert unfurls[links[0].url] == SlackIncidentsMessageBuilder(incident, action).build() @patch("sentry.integrations.slack.unfurl.discover.generate_chart", return_value="chart-url") def test_unfurl_discover(self, mock_generate_chart): @@ -121,8 +124,11 @@ def test_unfurl_discover(self, mock_generate_chart): ): unfurls = link_handlers[link_type].fn(self.request, self.integration, links, self.user) - assert unfurls[url] == build_discover_attachment( - title=args["query"].get("name"), chart_url="chart-url" + assert ( + unfurls[url] + == SlackDiscoverMessageBuilder( + title=args["query"].get("name"), chart_url="chart-url" + ).build() ) assert len(mock_generate_chart.mock_calls) == 1 chart_data = mock_generate_chart.call_args[0][1] @@ -157,8 +163,11 @@ def test_unfurl_discover_previous_period(self, mock_generate_chart): ): unfurls = link_handlers[link_type].fn(self.request, self.integration, links, self.user) - assert unfurls[url] == build_discover_attachment( - title=args["query"].get("name"), chart_url="chart-url" + assert ( + unfurls[url] + == SlackDiscoverMessageBuilder( + title=args["query"].get("name"), chart_url="chart-url" + ).build() ) assert len(mock_generate_chart.mock_calls) == 1 assert mock_generate_chart.call_args[0][0] == ChartType.SLACK_DISCOVER_PREVIOUS_PERIOD @@ -194,8 +203,11 @@ def test_unfurl_discover_multi_y_axis(self, mock_generate_chart): ): unfurls = link_handlers[link_type].fn(self.request, self.integration, links, self.user) - assert unfurls[url] == build_discover_attachment( - title=args["query"].get("name"), chart_url="chart-url" + assert ( + unfurls[url] + == SlackDiscoverMessageBuilder( + title=args["query"].get("name"), chart_url="chart-url" + ).build() ) assert len(mock_generate_chart.mock_calls) == 1 chart_data = mock_generate_chart.call_args[0][1] @@ -231,8 +243,11 @@ def test_unfurl_discover_html_escaped(self, mock_generate_chart): ): unfurls = link_handlers[link_type].fn(self.request, self.integration, links, self.user) - assert unfurls[url] == build_discover_attachment( - title=args["query"].get("name"), chart_url="chart-url" + assert ( + unfurls[url] + == SlackDiscoverMessageBuilder( + title=args["query"].get("name"), chart_url="chart-url" + ).build() ) assert len(mock_generate_chart.mock_calls) == 1 chart_data = mock_generate_chart.call_args[0][1] @@ -287,8 +302,11 @@ def test_unfurl_discover_short_url(self, mock_generate_chart): ): unfurls = link_handlers[link_type].fn(self.request, self.integration, links, self.user) - assert unfurls[url] == build_discover_attachment( - title=args["query"].get("name"), chart_url="chart-url" + assert ( + unfurls[url] + == SlackDiscoverMessageBuilder( + title=args["query"].get("name"), chart_url="chart-url" + ).build() ) assert len(mock_generate_chart.mock_calls) == 1 @@ -351,8 +369,11 @@ def test_unfurl_correct_y_axis_for_saved_query(self, mock_generate_chart): ): unfurls = link_handlers[link_type].fn(self.request, self.integration, links, self.user) - assert unfurls[url] == build_discover_attachment( - title=args["query"].get("name"), chart_url="chart-url" + assert ( + unfurls[url] + == SlackDiscoverMessageBuilder( + title=args["query"].get("name"), chart_url="chart-url" + ).build() ) assert len(mock_generate_chart.mock_calls) == 1 @@ -393,8 +414,11 @@ def test_top_events_url_param(self, mock_generate_chart): ): unfurls = link_handlers[link_type].fn(self.request, self.integration, links, self.user) - assert unfurls[url] == build_discover_attachment( - title=args["query"].get("name"), chart_url="chart-url" + assert ( + unfurls[url] + == SlackDiscoverMessageBuilder( + title=args["query"].get("name"), chart_url="chart-url" + ).build() ) assert len(mock_generate_chart.mock_calls) == 1 @@ -448,8 +472,11 @@ def test_unfurl_discover_short_url_without_project_ids(self, mock_generate_chart ): unfurls = link_handlers[link_type].fn(self.request, self.integration, links, self.user) - assert unfurls[url] == build_discover_attachment( - title=args["query"].get("name"), chart_url="chart-url" + assert ( + unfurls[url] + == SlackDiscoverMessageBuilder( + title=args["query"].get("name"), chart_url="chart-url" + ).build() ) assert len(mock_generate_chart.mock_calls) == 1 @@ -487,8 +514,11 @@ def test_unfurl_discover_without_project_ids(self, mock_generate_chart): ): unfurls = link_handlers[link_type].fn(self.request, self.integration, links, self.user) - assert unfurls[url] == build_discover_attachment( - title=args["query"].get("name"), chart_url="chart-url" + assert ( + unfurls[url] + == SlackDiscoverMessageBuilder( + title=args["query"].get("name"), chart_url="chart-url" + ).build() ) assert len(mock_generate_chart.mock_calls) == 1 chart_data = mock_generate_chart.call_args[0][1] @@ -533,8 +563,11 @@ def test_unfurl_world_map(self, mock_generate_chart): ): unfurls = link_handlers[link_type].fn(self.request, self.integration, links, self.user) - assert unfurls[url] == build_discover_attachment( - title=args["query"].get("name"), chart_url="chart-url" + assert ( + unfurls[url] + == SlackDiscoverMessageBuilder( + title=args["query"].get("name"), chart_url="chart-url" + ).build() ) assert len(mock_generate_chart.mock_calls) == 1