Repository: getsentry/sentry

Issue:
Show suspect commit, PR link, and author
If available, show the issue's suspect commit along with a link to the PR (if available), and the PR author.

See the design 



![Image](https://github.com/getsentry/sentry/assets/29959063/5ddc2c94-59b5-4aa8-9513-9106bd4cdf49)




Discussion:

Resolving patch:
diff --git a/src/sentry/integrations/slack/message_builder/base/block.py b/src/sentry/integrations/slack/message_builder/base/block.py
--- a/src/sentry/integrations/slack/message_builder/base/block.py
+++ b/src/sentry/integrations/slack/message_builder/base/block.py
@@ -6,6 +6,7 @@
 
 from sentry.integrations.slack.message_builder import SlackBlock
 from sentry.integrations.slack.message_builder.base.base import SlackMessageBuilder
+from sentry.notifications.utils.actions import MessageAction
 from sentry.utils.dates import to_timestamp
 
 
@@ -95,7 +96,7 @@ def get_button_action(action):
         return button
 
     @staticmethod
-    def get_link_button(action):
+    def get_link_button(action: MessageAction) -> SlackBlock:
         return {
             "type": "section",
             "text": {
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
@@ -55,6 +55,7 @@
 from sentry.types.group import SUBSTATUS_TO_STR
 from sentry.types.integrations import ExternalProviderEnum, ExternalProviders
 from sentry.utils import json
+from sentry.utils.committers import get_serialized_event_file_committers
 
 STATUSES = {"resolved": "resolved", "ignored": "ignored", "unresolved": "re-opened"}
 logger = logging.getLogger(__name__)
@@ -291,6 +292,60 @@ def get_suggested_assignees(
     return []
 
 
+def get_suspect_commit_block(project: Project, event: GroupEvent):
+    """Build up the suspect commit info for the given event"""
+    author = None
+    commit_id = None
+    commit_link = None
+    pr_link = None
+    pr_date = None
+    committers = None
+    repo_base = None
+
+    try:
+        committers = get_serialized_event_file_committers(project, event)
+    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 not committers:
+        return None
+
+    if committers:
+        committer = committers[0]
+        author = committer.get("author", {})
+        commits = committer.get("commits")
+        if commits:
+            commit_id = commits[0].get("id")
+            pull_request = commits[0].get("pullRequest")
+            if pull_request:
+                pr_title = pull_request.get("title")
+                pr_link = pull_request.get("externalUrl")
+                repo_base = pull_request.get("repository", {}).get("url")
+                pr_date = pull_request.get("dateCreated")
+                pr_id = pull_request.get("id")
+                if pr_date:
+                    pr_date = time_since(pr_date)
+
+    if author and commit_id:
+        author_display = (
+            author.get("name") if author.get("name") is not None else author.get("email")
+        )
+
+        suspect_commit_text = "Suspect Commit: "
+        if repo_base:
+            commit_link = f"<{repo_base}/commits/{commit_id}|{commit_id[0:6]}>"
+            suspect_commit_text += f"{commit_link} by {author_display}"
+        else:
+            suspect_commit_text += f"{commit_id} by {author_display}"
+        if pull_request:
+            suspect_commit_text += (
+                f" {pr_date} \n{pr_title} ({pr_id}) <{pr_link}|View Pull Request>"
+            )
+        return suspect_commit_text
+    return None
+
+
 def get_action_text(text: str, actions: Sequence[Any], identity: RpcIdentity) -> str:
     action_text = "\n".join(
         [
@@ -581,6 +636,11 @@ def build(self, notification_uuid: str | None = None) -> Union[SlackBlock, Slack
                 self.get_context_block(suggested_assignee_text[:-2])
             )  # get rid of comma at the end
 
+        # add suspect commit info
+        suspect_commit_text = get_suspect_commit_block(project, event_for_tags)
+        if suspect_commit_text:
+            blocks.append(self.get_context_block(suspect_commit_text))
+
         # add notes
         if self.notes:
             notes_text = f"notes: {self.notes}"


Test patch:
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
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import copy
 from datetime import datetime
 from typing import Any
 from unittest.mock import Mock, patch
@@ -30,6 +31,7 @@
 from sentry.models.groupowner import GroupOwner, GroupOwnerType
 from sentry.models.identity import Identity, IdentityStatus
 from sentry.models.projectownership import ProjectOwnership
+from sentry.models.pullrequest import PullRequest
 from sentry.models.repository import Repository
 from sentry.models.team import Team
 from sentry.models.user import User
@@ -38,6 +40,7 @@
 from sentry.services.hybrid_cloud.actor import RpcActor
 from sentry.silo.base import SiloMode
 from sentry.testutils.cases import PerformanceIssueTestCase, TestCase
+from sentry.testutils.factories import DEFAULT_EVENT_DATA
 from sentry.testutils.helpers.datetime import before_now, iso_format
 from sentry.testutils.helpers.features import with_feature
 from sentry.testutils.silo import assume_test_silo_mode, region_silo_test
@@ -59,6 +62,7 @@ def build_test_message_blocks(
     suggested_assignees: str | None = None,
     initial_assignee: Team | User | None = None,
     notes: str | None = None,
+    suspect_commit: dict[str, str] | None = None,
 ) -> dict[str, Any]:
     project = group.project
 
@@ -153,6 +157,34 @@ def build_test_message_blocks(
         }
         blocks.append(suggested_assignees_section)
 
+    if suspect_commit:
+        suspect_commit_text = "Suspect Commit: "
+        if suspect_commit.get("repo_base"):
+            commit_link = f"<{suspect_commit['repo_base']}/commits/{suspect_commit['commit_id']}|{suspect_commit['commit_id'][0:6]}>"
+            suspect_commit_text += f"{commit_link}"
+        else:
+            suspect_commit_text += f"{suspect_commit['commit_id']}"
+        suspect_commit_name = (
+            suspect_commit["author_name"]
+            if suspect_commit.get("author_name")
+            else suspect_commit["author_email"]
+        )
+        suspect_commit_text += f" by {suspect_commit_name}"
+
+        if suspect_commit.get("time_since"):
+            suspect_commit_text += f" {suspect_commit['time_since']}"
+
+        if suspect_commit.get("pr_link") and suspect_commit.get("pr_id"):
+            pr_link = f"<{suspect_commit['pr_link']}|View Pull Request>"
+            pr_title_text = f"{suspect_commit['pr_title']} ({suspect_commit['pr_id']})"
+
+            suspect_commit_text += f" \n{pr_title_text} {pr_link}"
+        suspect_commit_section = {
+            "type": "context",
+            "elements": [{"type": "mrkdwn", "text": suspect_commit_text}],
+        }
+        blocks.append(suspect_commit_section)
+
     if notes:
         notes_text = f"notes: {notes}"
         notes_section = {
@@ -448,6 +480,79 @@ def test_build_group_attachment_issue_alert_block_kit(self):
         for section in ret["blocks"]:
             assert section["type"] != "actions"
 
+    @patch(
+        "sentry.api.serializers.models.pullrequest.PullRequestSerializer._external_url",
+        return_value="https://github.com/meowmeow/cats/pull/1",
+    )
+    @with_feature("organizations:slack-block-kit")
+    def test_issue_alert_with_suspect_commits(self, mock_external_url):
+        self.login_as(user=self.user)
+        self.project.flags.has_releases = True
+        self.project.save(update_fields=["flags"])
+        self.repo = Repository.objects.create(
+            organization_id=self.organization.id,
+            name="example",
+            integration_id=self.integration.id,
+            url="http://www.github.com/meowmeow/cats",
+        )
+        commit_author = self.create_commit_author(project=self.project, user=self.user)
+        self.commit = self.create_commit(
+            project=self.project,
+            repo=self.repo,
+            author=commit_author,
+            key="asdfwreqr",
+            message="placeholder commit message",
+        )
+        pull_request = PullRequest.objects.create(
+            organization_id=self.organization.id,
+            repository_id=self.repo.id,
+            key="9",
+            author=commit_author,
+            message="waddap",
+            title="cool pr",
+            merge_commit_sha=self.commit.key,
+        )
+        event = self.store_event(
+            data={
+                "fingerprint": ["group1"],
+                "timestamp": iso_format(before_now(minutes=1)),
+                "stacktrace": copy.deepcopy(DEFAULT_EVENT_DATA["stacktrace"]),
+                "logentry": {"formatted": "bar"},
+                "_meta": {"logentry": {"formatted": {"": {"err": ["some error"]}}}},
+            },
+            project_id=self.project.id,
+            assert_no_errors=False,
+        )
+        assert event.group
+        group = event.group
+
+        GroupOwner.objects.create(
+            group=event.group,
+            user_id=self.user.id,
+            project=self.project,
+            organization=self.organization,
+            type=GroupOwnerType.SUSPECT_COMMIT.value,
+            context={"commitId": self.commit.id},
+        )
+        suspect_commit = {
+            "commit_id": self.commit.key,
+            "author_email": commit_author.email,
+            "time_since": "Just now",
+            "pr_title": pull_request.title,
+            "pr_link": mock_external_url.return_value,
+            "pr_id": pull_request.key,
+            "repo_base": self.repo.url,
+        }
+        assert SlackIssuesMessageBuilder(
+            group, event.for_group(group)
+        ).build() == build_test_message_blocks(
+            teams={self.team},
+            users={self.user},
+            group=group,
+            event=event,
+            suspect_commit=suspect_commit,
+        )
+
     @with_feature("organizations:slack-block-kit")
     @with_feature("organizations:streamline-targeting-context")
     def test_issue_alert_with_suggested_assignees(self):
@@ -496,8 +601,9 @@ def test_issue_alert_with_suggested_assignees(self):
             name="home-repo",
             integration_id=self.integration.id,
         )
-        user2 = self.create_user()
+        user2 = self.create_user(name="Scooby Doo")
         self.create_member(teams=[self.team], user=user2, organization=self.organization)
+
         commit = self.create_commit(
             project=self.project,
             repo=repo,
@@ -516,6 +622,11 @@ def test_issue_alert_with_suggested_assignees(self):
 
         # auto assign group
         ProjectOwnership.handle_auto_assignment(self.project.id, event)
+        suspect_commit = {
+            "commit_id": commit.key,
+            "author_email": commit.author.email,
+            "author_name": commit.author.name,
+        }
         expected_blocks = build_test_message_blocks(
             teams={self.team},
             users={self.user},
@@ -523,19 +634,14 @@ def test_issue_alert_with_suggested_assignees(self):
             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
+            suspect_commit=suspect_commit,
         )
 
         # 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