Repository: getsentry/sentry

Issue:
Display issue assignee if available, suggested assignees if not
Add the issue assignee to issue alerts if available, or show the suggested assignees (if available). If there are no suggested assignees, just show "unassigned". Render the user's email / team name rather than doing `@colleen` for example, so as to not notify multiple suggested assignees in Slack.


Discussion:

Resolving patch:
diff --git a/src/sentry/integrations/slack/message_builder/issues.py b/src/sentry/integrations/slack/message_builder/issues.py
--- a/src/sentry/integrations/slack/message_builder/issues.py
+++ b/src/sentry/integrations/slack/message_builder/issues.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import logging
 from datetime import datetime, timedelta
 from typing import Any, Mapping, Sequence, Union
 
@@ -33,20 +34,29 @@
 from sentry.integrations.slack.utils.escape import escape_slack_text
 from sentry.issues.grouptype import GroupCategory
 from sentry.models.actor import ActorTuple
+from sentry.models.commit import Commit
 from sentry.models.group import Group, GroupStatus
 from sentry.models.project import Project
+from sentry.models.projectownership import ProjectOwnership
+from sentry.models.release import Release
 from sentry.models.rule import Rule
 from sentry.models.team import Team
 from sentry.models.user import User
 from sentry.notifications.notifications.base import ProjectNotification
 from sentry.notifications.utils.actions import MessageAction
+from sentry.notifications.utils.participants import (
+    dedupe_suggested_assignees,
+    get_suspect_commit_users,
+)
 from sentry.services.hybrid_cloud.actor import ActorType, RpcActor
 from sentry.services.hybrid_cloud.identity import RpcIdentity, identity_service
+from sentry.services.hybrid_cloud.user.model import RpcUser
 from sentry.types.group import SUBSTATUS_TO_STR
-from sentry.types.integrations import ExternalProviders
+from sentry.types.integrations import ExternalProviderEnum, ExternalProviders
 from sentry.utils import json
 
 STATUSES = {"resolved": "resolved", "ignored": "ignored", "unresolved": "re-opened"}
+logger = logging.getLogger(__name__)
 
 
 def time_since(value: datetime):
@@ -218,6 +228,59 @@ def get_group_assignees(group: Group) -> Sequence[Mapping[str, Any]]:
     return option_groups
 
 
+def get_suggested_assignees(
+    project: Project, event: GroupEvent, current_assignee: RpcUser | Team | None
+) -> list[str]:
+    """Get suggested assignees as a list of formatted strings"""
+    suggested_assignees = []
+    issue_owners, _ = ProjectOwnership.get_owners(project.id, event.data)
+    if (
+        issue_owners != ProjectOwnership.Everyone
+    ):  # we don't want every user in the project to be a suggested assignee
+        resolved_owners = ActorTuple.resolve_many(issue_owners)
+        suggested_assignees = RpcActor.many_from_object(resolved_owners)
+    if features.has("organizations:streamline-targeting-context", project.organization):
+        try:
+            suspect_commit_users = RpcActor.many_from_object(
+                get_suspect_commit_users(project, event)
+            )
+            suggested_assignees.extend(suspect_commit_users)
+        except (Release.DoesNotExist, Commit.DoesNotExist):
+            logger.info("Skipping suspect committers because release does not exist.")
+        except Exception:
+            logger.exception("Could not get suspect committers. Continuing execution.")
+    if suggested_assignees:
+        suggested_assignees = dedupe_suggested_assignees(suggested_assignees)
+        assignee_texts = []
+        for assignee in suggested_assignees:
+            # skip over any suggested assignees that are the current assignee of the issue, if there is any
+            if assignee.actor_type == ActorType.USER and not (
+                isinstance(current_assignee, RpcUser) and assignee.id == current_assignee.id
+            ):
+                # for user assignees, we first try to get their Slack identity; if it's not linked,
+                # we use their display name linked with their email
+                assignee_identity = None
+                assignee_identities = identity_service.get_user_identities_by_provider_type(
+                    user_id=assignee.id, provider_type=ExternalProviderEnum.SLACK.value
+                )
+                if len(assignee_identities) > 0:
+                    assignee_identity = assignee_identities[0]
+                if assignee_identity is None:
+                    assignee_as_user = assignee.resolve()
+                    assignee_text = (
+                        f"<mailto:{assignee_as_user.email}|{assignee_as_user.get_display_name()}>"
+                    )
+                else:
+                    assignee_text = f"<@{assignee_identity.external_id}>"
+                assignee_texts.append(assignee_text)
+            elif assignee.actor_type == ActorType.TEAM and not (
+                isinstance(current_assignee, Team) and assignee.id == current_assignee.id
+            ):
+                assignee_texts.append(f"#{assignee.slug}")
+        return assignee_texts
+    return []
+
+
 def get_action_text(text: str, actions: Sequence[Any], identity: RpcIdentity) -> str:
     action_text = "\n".join(
         [
@@ -471,6 +534,7 @@ def build(self, notification_uuid: str | None = None) -> Union[SlackBlock, Slack
 
         # build actions
         actions = []
+        assignee = self.group.get_assignee()
         for action in payload_actions:
             if action.label in (
                 "Archive",
@@ -483,7 +547,6 @@ def build(self, notification_uuid: str | None = None) -> Union[SlackBlock, Slack
             ):
                 actions.append(self.get_button_action(action))
             elif action.name == "assign":
-                assignee = self.group.get_assignee()
                 actions.append(
                     self.get_external_select_action(
                         action, format_actor_option(assignee, True) if assignee else None
@@ -494,6 +557,20 @@ def build(self, notification_uuid: str | None = None) -> Union[SlackBlock, Slack
             action_block = {"type": "actions", "elements": [action for action in actions]}
             blocks.append(action_block)
 
+        # suggested assignees
+        suggested_assignees = []
+        if event_for_tags:
+            suggested_assignees = get_suggested_assignees(
+                self.group.project, event_for_tags, assignee
+            )
+        if len(suggested_assignees) > 0:
+            suggested_assignee_text = "Suggested Assignees: "
+            for assignee in suggested_assignees:
+                suggested_assignee_text += assignee + ", "
+            blocks.append(
+                self.get_context_block(suggested_assignee_text[:-2])
+            )  # get rid of comma at the end
+
         # add mentions
         if self.mentions:
             mentions_text = f"Mentions: {self.mentions}"


Test patch:
diff --git a/tests/sentry/integrations/slack/notifications/test_issue_alert.py b/tests/sentry/integrations/slack/notifications/test_issue_alert.py
--- a/tests/sentry/integrations/slack/notifications/test_issue_alert.py
+++ b/tests/sentry/integrations/slack/notifications/test_issue_alert.py
@@ -653,8 +653,9 @@ def test_issue_alert_team_issue_owners_block(self):
             blocks[1]["text"]["text"]
             == f":exclamation: <http://testserver/organizations/{event.organization.slug}/issues/{event.group.id}/?referrer=issue_alert-slack&notification_uuid={notification_uuid}&alert_rule_id={rule.id}&alert_type=issue|*Hello world*>  \n"
         )
+        assert blocks[5]["elements"][0]["text"] == f"Suggested Assignees: #{self.team.slug}"
         assert (
-            blocks[5]["elements"][0]["text"]
+            blocks[6]["elements"][0]["text"]
             == f"{event.project.slug} | <http://testserver/settings/{event.organization.slug}/teams/{self.team.slug}/notifications/?referrer=issue_alert-slack-team&notification_uuid={notification_uuid}|Notification Settings>"
         )
 
diff --git a/tests/sentry/integrations/slack/test_message_builder.py b/tests/sentry/integrations/slack/test_message_builder.py
--- a/tests/sentry/integrations/slack/test_message_builder.py
+++ b/tests/sentry/integrations/slack/test_message_builder.py
@@ -26,14 +26,20 @@
 )
 from sentry.models.group import Group, GroupStatus
 from sentry.models.groupassignee import GroupAssignee
+from sentry.models.groupowner import GroupOwner, GroupOwnerType
+from sentry.models.identity import Identity, IdentityStatus
+from sentry.models.projectownership import ProjectOwnership
+from sentry.models.repository import Repository
 from sentry.models.team import Team
 from sentry.models.user import User
 from sentry.notifications.utils.actions import MessageAction
+from sentry.ownership.grammar import Matcher, Owner, Rule, dump_schema
 from sentry.services.hybrid_cloud.actor import RpcActor
+from sentry.silo.base import SiloMode
 from sentry.testutils.cases import PerformanceIssueTestCase, TestCase
 from sentry.testutils.helpers.datetime import before_now, iso_format
 from sentry.testutils.helpers.features import with_feature
-from sentry.testutils.silo import region_silo_test
+from sentry.testutils.silo import assume_test_silo_mode, region_silo_test
 from sentry.testutils.skips import requires_snuba
 from sentry.utils.dates import to_timestamp
 from sentry.utils.http import absolute_uri
@@ -49,7 +55,9 @@ def build_test_message_blocks(
     event: Event | None = None,
     link_to_event: bool = False,
     tags: dict[str, str] | None = None,
+    suggested_assignees: str | None = None,
     mentions: str | None = None,
+    initial_assignee: Team | User | None = None,
 ) -> dict[str, Any]:
     project = group.project
 
@@ -94,7 +102,7 @@ def build_test_message_blocks(
     }
     blocks.append(counts_section)
 
-    actions = {
+    actions: dict[str, Any] = {
         "type": "actions",
         "elements": [
             {
@@ -120,8 +128,27 @@ def build_test_message_blocks(
             },
         ],
     }
+    if initial_assignee:
+        if isinstance(initial_assignee, User):
+            actions["elements"][2]["initial_option"] = {
+                "text": {"type": "plain_text", "text": f"{initial_assignee.email}"},
+                "value": f"user:{initial_assignee.id}",
+            }
+        else:
+            actions["elements"][2]["initial_option"] = {
+                "text": {"type": "plain_text", "text": f"#{initial_assignee.slug}"},
+                "value": f"team:{initial_assignee.id}",
+            }
     blocks.append(actions)
 
+    if suggested_assignees:
+        suggested_assignees_text = f"Suggested Assignees: {suggested_assignees}"
+        suggested_assignees_section = {
+            "type": "context",
+            "elements": [{"type": "mrkdwn", "text": suggested_assignees_text}],
+        }
+        blocks.append(suggested_assignees_section)
+
     if mentions:
         mentions_text = f"Mentions: {mentions}"
         mentions_section = {
@@ -409,6 +436,118 @@ def test_build_group_attachment_issue_alert_block_kit(self):
         for section in ret["blocks"]:
             assert section["type"] != "actions"
 
+    @with_feature("organizations:slack-block-kit")
+    @with_feature("organizations:streamline-targeting-context")
+    def test_issue_alert_with_suggested_assignees(self):
+        self.project.flags.has_releases = True
+        self.project.save(update_fields=["flags"])
+        event = self.store_event(
+            data={
+                "message": "Hello world",
+                "level": "error",
+                "stacktrace": {"frames": [{"filename": "foo.py"}]},
+            },
+            project_id=self.project.id,
+        )
+        assert event.group
+        group = event.group
+
+        # create codeowner; user with no slack identity linked
+        self.code_mapping = self.create_code_mapping(project=self.project)
+        g_rule1 = Rule(Matcher("path", "*"), [Owner("team", self.team.slug)])
+        self.create_codeowners(self.project, self.code_mapping, schema=dump_schema([g_rule1]))
+        GroupOwner.objects.create(
+            group=group,
+            type=GroupOwnerType.CODEOWNERS.value,
+            user_id=None,
+            team_id=self.team.id,
+            project=self.project,
+            organization=self.organization,
+            context={"rule": str(g_rule1)},
+        )
+
+        # create ownership rule
+        g_rule2 = Rule(Matcher("level", "error"), [Owner("user", self.user.email)])
+        GroupOwner.objects.create(
+            group=group,
+            type=GroupOwnerType.OWNERSHIP_RULE.value,
+            user_id=self.user.id,
+            team_id=None,
+            project=self.project,
+            organization=self.organization,
+            context={"rule": str(g_rule2)},
+        )
+
+        # create suspect commit
+        repo = Repository.objects.create(
+            organization_id=self.organization.id,
+            name="home-repo",
+            integration_id=self.integration.id,
+        )
+        user2 = self.create_user()
+        self.create_member(teams=[self.team], user=user2, organization=self.organization)
+        commit = self.create_commit(
+            project=self.project,
+            repo=repo,
+            author=self.create_commit_author(project=self.project, user=user2),
+            key="qwertyuiopiuytrewq",
+            message="This is a suspect commit!",
+        )
+        GroupOwner.objects.create(
+            group=group,
+            user_id=user2.id,
+            project=self.project,
+            organization=self.organization,
+            type=GroupOwnerType.SUSPECT_COMMIT.value,
+            context={"commitId": commit.id},
+        )
+
+        # auto assign group
+        ProjectOwnership.handle_auto_assignment(self.project.id, event)
+
+        expected_blocks = build_test_message_blocks(
+            teams={self.team},
+            users={self.user},
+            group=group,
+            event=event,
+            suggested_assignees=f"#{self.team.slug}, <mailto:{user2.email}|{user2.email}>",  # auto-assignee is not included in suggested
+            initial_assignee=self.user,
+        )
+        assert (
+            SlackIssuesMessageBuilder(group, event.for_group(group), tags={"foo"}).build()
+            == expected_blocks
+        )
+
+        # suggested user without slack identity linked, with display name
+        user2.name = "Scooby Doo"
+        with assume_test_silo_mode(SiloMode.CONTROL):
+            user2.save()
+        expected_blocks["blocks"][4]["elements"][0][
+            "text"
+        ] = f"Suggested Assignees: #{self.team.slug}, <mailto:{user2.email}|{user2.name}>"
+        assert (
+            SlackIssuesMessageBuilder(group, event.for_group(group), tags={"foo"}).build()
+            == expected_blocks
+        )
+
+        # suggested user with slack identity linked
+        with assume_test_silo_mode(SiloMode.CONTROL):
+            self.idp = self.create_identity_provider(type="slack", external_id="TXXXXXXX2")
+            self.identity = Identity.objects.create(
+                external_id="UXXXXXXX2",
+                idp=self.idp,
+                user=user2,
+                status=IdentityStatus.VALID,
+                scopes=[],
+            )
+        expected_blocks["blocks"][4]["elements"][0][
+            "text"
+        ] = f"Suggested Assignees: #{self.team.slug}, <@{self.identity.external_id}>"
+        assert (
+            SlackIssuesMessageBuilder(group, event.for_group(group), tags={"foo"}).build()
+            == expected_blocks
+        )
+
     def test_team_recipient(self):
         issue_alert_group = self.create_group(project=self.project)
         ret = SlackIssuesMessageBuilder(