Skip to content

Commit 001187b

Browse files
authored
feat(notifications): Add suspect commits to workflow notification emails (#96152)
## Problem Workflow notification emails (assignment, resolution, comments, etc.) don't include suspect commit information, unlike issue alert emails. This creates an inconsistent user experience where some notifications show helpful commit context while others don't. ## Solution This PR moves the suspect commit part of Issue Alert emails into a reusable component that all Issue notifications can use. ### Changes Made - **Backend**: - Modified `GroupActivityNotification.get_context()` to include suspect commits - added check for `enhanced_privacy` - when `enhanced_privacy` is set, the email will not include suspect commit details - **Templates**: Created shared template fragments: - `_suspect_commits.txt` for plain text emails - `_suspect_commits.html` for HTML emails - **Updated Templates**: All email templates now include suspect commits: - Issue alerts: `error.txt/html`, `performance.txt/html`, `feedback.txt/html`, `generic.txt/html` - Workflow notifications: `activity/generic.txt/html` ### Screenshots type: error alert, template source has changed but `Before` and `After` should look identical html: <img width="4498" height="1132" alt="Screenshot 2025-08-12 at 2 38 27 PM" src="https://github.com/user-attachments/assets/ffa24bd1-1f89-4f72-92b1-de06438ab608" /> txt: <img width="3438" height="1340" alt="Screenshot 2025-08-12 at 2 40 33 PM" src="https://github.com/user-attachments/assets/405b9a62-332c-4d15-ad73-a9653f4f662e" /> type: assigned, added suspect commit template html: <img width="3924" height="1032" alt="Screenshot 2025-08-12 at 2 43 49 PM" src="https://github.com/user-attachments/assets/3b252535-3168-4354-9a4f-b6b84dbf211a" /> txt: <img width="4020" height="568" alt="Screenshot 2025-08-12 at 2 46 14 PM" src="https://github.com/user-attachments/assets/73fd4bc0-e398-43a1-860c-c11e17063920" />
1 parent 1d8114c commit 001187b

File tree

18 files changed

+724
-118
lines changed

18 files changed

+724
-118
lines changed

fixtures/emails/note.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ goose smiling gobbler sterling feline
1111

1212
http://testserver/organizations/organization/issues/1/activity/?referrer=note_activity-email&notification_uuid=6a372642-0ffc-43c6-b0d3-7ce2752f38a9
1313

14+
1415
Unsubscribe: javascript:alert("This is a preview page, what did you expect to happen?");

src/sentry/features/temporary.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,8 @@ def register_temporary_features(manager: FeatureManager):
366366
manager.add("organizations:feature-flag-distribution-flyout", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
367367
# Enable suspect feature flags endpoint.
368368
manager.add("organizations:feature-flag-suspect-flags", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
369+
# Enable suspect commit information in workflow notification emails
370+
manager.add("organizations:suspect-commits-in-emails", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, default=False, api_expose=False)
369371
# Enable suspect feature tags endpoint.
370372
manager.add("organizations:issues-suspect-tags", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
371373
# Enable suspect score display in distributions drawer (internal only)

src/sentry/notifications/notifications/activity/base.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@
99
from django.utils.html import format_html
1010
from django.utils.safestring import SafeString
1111

12+
from sentry import features
1213
from sentry.db.models import Model
1314
from sentry.integrations.types import ExternalProviders
1415
from sentry.notifications.helpers import get_reason_context
1516
from sentry.notifications.notifications.base import ProjectNotification
1617
from sentry.notifications.types import NotificationSettingEnum, UnsubscribeContext
17-
from sentry.notifications.utils import send_activity_notification
18+
from sentry.notifications.utils import get_suspect_commits_by_group_id, send_activity_notification
1819
from sentry.notifications.utils.avatar import avatar_as_html
1920
from sentry.notifications.utils.participants import ParticipantMap, get_participants_for_group
2021
from sentry.types.actor import Actor
@@ -129,12 +130,25 @@ def get_context(self, provider: ExternalProviders | None = None) -> MutableMappi
129130
should_add_url = provider is not None
130131
text_description = self.description_as_text(text_template, params, should_add_url, provider)
131132
html_description = self.description_as_html(html_template or text_template, params)
132-
return {
133+
enhanced_privacy = self.group.organization.flags.enhanced_privacy
134+
135+
context = {
133136
**self.get_base_context(),
134137
"text_description": text_description,
135138
"html_description": html_description,
139+
"enhanced_privacy": enhanced_privacy,
136140
}
137141

142+
if (
143+
features.has("organizations:suspect-commits-in-emails", self.group.organization)
144+
and self.group
145+
):
146+
context["commits"] = get_suspect_commits_by_group_id(
147+
project=self.project, group_id=self.group.id
148+
)
149+
150+
return context
151+
138152
def get_group_context(self) -> MutableMapping[str, Any]:
139153
group_link = self.get_group_link()
140154
parts = list(urlparse(group_link))

src/sentry/notifications/utils/__init__.py

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import TYPE_CHECKING, Any, Optional, TypedDict, cast
99
from urllib.parse import parse_qs, urlparse
1010

11+
import sentry_sdk
1112
from django.db.models import Count
1213
from django.utils.safestring import mark_safe
1314
from django.utils.translation import gettext_lazy as _
@@ -40,7 +41,11 @@
4041
from sentry.silo.base import region_silo_function
4142
from sentry.types.rules import NotificationRuleDetails
4243
from sentry.users.services.user import RpcUser
43-
from sentry.utils.committers import get_serialized_event_file_committers
44+
from sentry.utils.committers import (
45+
AuthorCommitsSerialized,
46+
get_serialized_committers,
47+
get_serialized_event_file_committers,
48+
)
4449
from sentry.web.helpers import render_to_string
4550

4651
if TYPE_CHECKING:
@@ -132,30 +137,71 @@ def get_rules(
132137
]
133138

134139

140+
def process_serialized_committers(
141+
committers: Sequence[AuthorCommitsSerialized], commits: MutableMapping[str, Mapping[str, Any]]
142+
) -> MutableMapping[str, Mapping[str, Any]]:
143+
"""
144+
Transform committer data from nested structure to flat commit dictionary.
145+
146+
Args:
147+
committers: List of {author, commits}
148+
commits: Dict to store processed commits (modified in-place)
149+
150+
Returns:
151+
Dict mapping commit IDs to enriched commit data with shortId, author, subject
152+
"""
153+
for committer in committers:
154+
for commit in committer["commits"]:
155+
if commit["id"] not in commits:
156+
commit_data = dict(commit)
157+
commit_data["shortId"] = commit_data["id"][:7]
158+
commit_data["author"] = committer["author"]
159+
commit_data["subject"] = (
160+
commit_data["message"].split("\n", 1)[0] if commit_data["message"] else ""
161+
)
162+
if commit.get("pullRequest"):
163+
commit_data["pull_request"] = commit["pullRequest"]
164+
commits[commit["id"]] = commit_data
165+
return commits
166+
167+
168+
def get_suspect_commits_by_group_id(
169+
project: Project,
170+
group_id: int,
171+
) -> Sequence[Mapping[str, Any]]:
172+
"""
173+
Get suspect commits for workflow notifications that only have a group ID (no Event).
174+
175+
Uses Commit Context (SCM integrations) only - no release-based fallback.
176+
Returns commits sorted by recency, not suspicion score.
177+
"""
178+
commits: MutableMapping[str, Mapping[str, Any]] = {}
179+
try:
180+
committers = get_serialized_committers(project, group_id)
181+
except (Commit.DoesNotExist, Release.DoesNotExist):
182+
pass
183+
except Exception as exc:
184+
sentry_sdk.capture_exception(exc)
185+
else:
186+
commits = process_serialized_committers(committers=committers, commits=commits)
187+
return list(commits.values())
188+
189+
135190
def get_commits(project: Project, event: Event) -> Sequence[Mapping[str, Any]]:
136-
# lets identify possibly suspect commits and owners
137-
commits: MutableMapping[int, Mapping[str, Any]] = {}
191+
# let's identify possible suspect commits and owners
192+
commits: MutableMapping[str, Mapping[str, Any]] = {}
138193
try:
139194
committers = get_serialized_event_file_committers(project, event)
140195
except (Commit.DoesNotExist, Release.DoesNotExist):
141196
pass
142197
except Exception as exc:
143198
logging.exception(str(exc))
144199
else:
145-
for committer in committers:
146-
for commit in committer["commits"]:
147-
if commit["id"] not in commits:
148-
commit_data = dict(commit)
149-
commit_data["shortId"] = commit_data["id"][:7]
150-
commit_data["author"] = committer["author"]
151-
commit_data["subject"] = (
152-
commit_data["message"].split("\n", 1)[0] if commit_data["message"] else ""
153-
)
154-
if commit.get("pullRequest"):
155-
commit_data["pull_request"] = commit["pullRequest"]
156-
commits[commit["id"]] = commit_data
200+
commits = process_serialized_committers(committers=committers, commits=commits)
157201
# TODO(nisanthan): Once Commit Context is GA, no need to sort by "score"
158202
# commits from Commit Context dont have a "score" key
203+
# keep this sort while release_based SuspectCommitStrategy is active.
204+
# scm_based SuspectCommitStrategy does not use this scoring system.
159205
return sorted(commits.values(), key=lambda x: float(x.get("score", 0)), reverse=True)
160206

161207

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{% load sentry_avatars %}
2+
{% if commits and not enhanced_privacy %}
3+
<div class="committers">
4+
<h3 class="title" style="margin-bottom: 10px">Suspect Commits</h3>
5+
<table class="table commit-table">
6+
{% for commit in commits %}
7+
<tr>
8+
<td style="padding:0;width:32px;">{% email_avatar commit.author.name commit.author.email 32 %}</td>
9+
<td>
10+
<h5 class="truncate">{{ commit.subject }}</h5>
11+
<div><small>{{ commit.shortId }}&nbsp;&mdash;&nbsp;
12+
{% if commit.author %}
13+
<strong>{{ commit.author.name }}</strong>
14+
{% else %}
15+
<strong>Unknown Author</strong>
16+
{% endif %}
17+
</small></div>
18+
</td>
19+
</tr>
20+
{% endfor %}
21+
</table>
22+
</div>
23+
{% endif %}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{% if commits and not enhanced_privacy %}
2+
Suspect Commits
3+
---------------
4+
{% for commit in commits %}
5+
* {{ commit.subject }}
6+
{{ commit.shortId }} - {% if commit.author %}{{ commit.author.name }}{% else %}Unknown Author{% endif %}
7+
{% endfor %}{% endif %}

src/sentry/templates/sentry/emails/activity/generic.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
{% load sentry_helpers %}
44
{% load sentry_assets %}
5+
{% load sentry_avatars %}
56

67
{% block header %}
78
{% block action %}
@@ -32,6 +33,8 @@ <h2>{{ title }}</h2>
3233
{% include "sentry/emails/group_header.html" %}
3334
{% endif %}
3435

36+
{% include "sentry/emails/_suspect_commits.html" %}
37+
3538
{% if reason %}
3639
<p class="via">
3740
You are receiving this email because you {{ reason }}.

src/sentry/templates/sentry/emails/activity/generic.txt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010
{{ group.title }}
1111

1212
{{ link }}
13-
{% if unsubscribe_link %}
14-
13+
{% include "sentry/emails/_suspect_commits.txt" %}{% if unsubscribe_link %}
1514
Unsubscribe: {{ unsubscribe_link }}{% endif %}
1615

1716
{% endautoescape %}

src/sentry/templates/sentry/emails/activity/note.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
{{ group.title }}
1313

1414
{{ activity_link }}
15-
15+
{% include "sentry/emails/_suspect_commits.txt" %}
1616
Unsubscribe: {{ unsubscribe_link }}
1717

1818
{% endautoescape %}

src/sentry/templates/sentry/emails/error.txt

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,7 @@ Details
1414

1515
{{ link }}
1616

17-
{% if commits %}
18-
Suspect Commits
19-
---------------
20-
{% for commit in commits %}
21-
* {{ commit.subject }}
22-
{{ commit.shortId }} - {% if commit.author %}{{ commit.author.name }}{% else %}Unknown Author{% endif %}
23-
{% endfor %}{% endif %}
24-
17+
{% include "sentry/emails/_suspect_commits.txt" %}
2518
Tags
2619
----
2720
{% for tag_key, tag_value in tags %}

0 commit comments

Comments
 (0)