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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,8 @@ def register_temporary_features(manager: FeatureManager) -> None:
manager.add("organizations:autofix-on-explorer", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable Seer Workflows in Slack
manager.add("organizations:seer-slack-workflows", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable new compact issue alert UI in Slack
manager.add("organizations:slack-compact-alerts", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable search query builder boolean operator select feature
manager.add("organizations:search-query-builder-add-boolean-operator-select", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable search query builder conditionals in combobox menus
Expand Down
88 changes: 63 additions & 25 deletions src/sentry/integrations/slack/message_builder/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,9 @@ def __init__(
self.skip_fallback = skip_fallback
self.notes = notes
self.issue_summary: dict[str, Any] | None = None
self._is_compact = features.has(
"organizations:slack-compact-alerts", self.group.organization
)

def get_title_block(
self,
Expand All @@ -515,6 +518,8 @@ def get_title_block(
summary_headline = self.get_issue_summary_headline(event_or_group)
title = summary_headline or build_attachment_title(event_or_group)
title_emojis = self.get_title_emoji(has_action)
if self._is_compact:
title = build_attachment_title(event_or_group)

title_text = f"{title_emojis} <{title_link}|*{escape_slack_text(title)}*>"
return self.get_markdown_block(title_text)
Expand All @@ -538,6 +543,7 @@ def get_title_emoji(self, has_action: bool) -> str:

return " ".join(title_emojis)

# Can be removed when 'slack-compact-alerts' is GA
def get_issue_summary_headline(self, event_or_group: Event | GroupEvent | Group) -> str | None:
if self.issue_summary is None:
return None
Expand Down Expand Up @@ -571,13 +577,18 @@ def get_issue_summary_text(self) -> str | None:

if not parts:
return None
return escape_slack_markdown_text("\n\n".join(parts))

if self._is_compact:
return f"*Initial Guess*: {escape_slack_markdown_text(' '.join(parts))}"
else:
return escape_slack_markdown_text("\n\n".join(parts))

def get_culprit_block(self, event_or_group: Event | GroupEvent | Group) -> SlackBlock | None:
if event_or_group.culprit and isinstance(event_or_group.culprit, str):
return self.get_context_block(event_or_group.culprit)
return None

# 'small' param can be removed when 'slack-compact-alerts' is GA
def get_text_block(self, text, small: bool = False) -> SlackBlock:
if self.group.issue_category == GroupCategory.FEEDBACK:
max_block_text_length = USER_FEEDBACK_MAX_BLOCK_TEXT_LENGTH
Expand All @@ -589,12 +600,26 @@ def get_text_block(self, text, small: bool = False) -> SlackBlock:
else:
return self.get_context_block(text)

# Can be removed when 'slack-compact-alerts' is GA
def get_suggested_assignees_block(self, suggested_assignees: list[str]) -> SlackBlock:
suggested_assignee_text = "Suggested Assignees: "
for assignee in suggested_assignees:
suggested_assignee_text += assignee + ", "
return self.get_context_block(suggested_assignee_text[:-2]) # get rid of comma at the end

def get_group_context_block(self, suggested_assignees: list[str]) -> SlackBlock | None:
"""Combine stats (events, users, state, first seen) with suggested assignees in one context block."""
context_text = get_context(self.group, self.rules)

if suggested_assignees:
suggested_text = ", ".join(suggested_assignees)
context_text += f" Suggested: {suggested_text}"

if not context_text:
return None

return self.get_context_block(context_text)

def get_footer(self) -> SlackBlock:
# This link does not contain user input (it's a static label and a url), must not escape it.
replay_link = build_attachment_replay_link(
Expand Down Expand Up @@ -719,18 +744,16 @@ def build(self, notification_uuid: str | None = None) -> SlackBlock:
if culprit_block := self.get_culprit_block(event_or_group):
blocks.append(culprit_block)

# Use issue summary if available, otherwise use the default text
if summary_text := self.get_issue_summary_text():
# Use issue summary if available (and not flagged for compact alerts), otherwise use the default text
summary_text = self.get_issue_summary_text()
if summary_text and not self._is_compact:
blocks.append(self.get_text_block(summary_text, small=True))
else:
text = text.lstrip(" ")
# XXX(CEO): sometimes text is " " and slack will error if we pass an empty string (now "")
if text:
blocks.append(self.get_text_block(text))

if self.actions:
blocks.append(self.get_markdown_block(action_text))

# set up block id
block_id = {"issue": self.group.id}
if rule_id:
Expand All @@ -741,18 +764,34 @@ def build(self, notification_uuid: str | None = None) -> SlackBlock:
if tags:
blocks.append(self.get_tags_block(tags, block_id))

# add event count, user count, substate, first seen
context = get_context(self.group, self.rules)
if context:
blocks.append(self.get_context_block(context))

# build actions
actions = []
try:
assignee = self.group.get_assignee()
except Actor.InvalidActor:
assignee = None

suggested_assignees = []
if event_for_tags:
suggested_assignees = get_suggested_assignees(
self.group.project, event_for_tags, assignee
)

if self._is_compact:
if group_context_block := self.get_group_context_block(
suggested_assignees=suggested_assignees
):
blocks.append(group_context_block)
else:
# add event count, user count, substate, first seen
context = get_context(self.group, self.rules)
if context:
blocks.append(self.get_context_block(context))

# If an action has been taken, add the text for it (e.g. "Issue resolved by <@U0234567890>")
if self.actions:
blocks.append(self.get_markdown_block(action_text))

# build actions
actions = []
for action in payload_actions:
if action.label in (
"Archive",
Expand Down Expand Up @@ -787,19 +826,17 @@ def build(self, notification_uuid: str | None = None) -> SlackBlock:
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:
if self._is_compact and summary_text:
blocks.append(self.get_context_block(summary_text))

if not self._is_compact and len(suggested_assignees) > 0:
blocks.append(self.get_suggested_assignees_block(suggested_assignees))

# add suspect commit info
suspect_commit_text = get_suspect_commit_text(self.group)
if suspect_commit_text:
blocks.append(self.get_context_block(suspect_commit_text))
if not self._is_compact:
# add suspect commit info
suspect_commit_text = get_suspect_commit_text(self.group)
if suspect_commit_text:
blocks.append(self.get_context_block(suspect_commit_text))

# add notes
if self.notes:
Expand All @@ -808,7 +845,8 @@ def build(self, notification_uuid: str | None = None) -> SlackBlock:

# build footer block
blocks.append(self.get_footer())
blocks.append(self.get_divider())
if not self._is_compact:
blocks.append(self.get_divider())

chart_block = ImageBlockBuilder(group=self.group).build_image_block()
if chart_block:
Expand Down
154 changes: 154 additions & 0 deletions tests/sentry/integrations/slack/test_message_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,160 @@ def test_build_group_block_with_ai_summary_without_org_acknowledgement(
blocks = SlackIssuesMessageBuilder(group).build()
assert "IntegrationError" in blocks["blocks"][0]["text"]["text"]

@with_feature("organizations:slack-compact-alerts")
def test_compact_alerts_basic_layout(self) -> None:
"""
Test that with the slack-compact-alerts flag enabled, the message uses a compact layout:
- No divider at the end
- Context block includes stats
"""
event = self.store_event(
data={
"event_id": "a" * 32,
"message": "IntegrationError",
"fingerprint": ["group-1"],
"exception": {
"values": [
{
"type": "IntegrationError",
"value": "Identity not found.",
}
]
},
"level": "error",
},
project_id=self.project.id,
)
assert event.group
group = event.group
group.type = ErrorGroupType.type_id
group.save()

self.project.flags.has_releases = True
self.project.save(update_fields=["flags"])

blocks = SlackIssuesMessageBuilder(group).build()

assert "IntegrationError" in blocks["blocks"][0]["text"]["text"]
assert blocks["blocks"][-1]["type"] != "divider"

@with_feature("organizations:slack-compact-alerts")
@override_options({"alerts.issue_summary_timeout": 5})
@with_feature({"organizations:gen-ai-features"})
@patch(
"sentry.integrations.utils.issue_summary_for_alerts.get_seer_org_acknowledgement",
return_value=True,
)
def test_compact_alerts_with_ai_summary(self, mock_get_seer_org_acknowledgement) -> None:
"""
Test that with the slack-compact-alerts flag enabled and AI summary available:
- Title uses build_attachment_title() (not AI headline)
- Issue summary appears after action buttons as context with "Initial Guess:" prefix
"""
event = self.store_event(
data={
"event_id": "a" * 32,
"message": "IntegrationError",
"fingerprint": ["group-1"],
"exception": {
"values": [
{
"type": "IntegrationError",
"value": "Identity not found.",
}
]
},
"level": "error",
},
project_id=self.project.id,
)
assert event.group
group = event.group
group.type = ErrorGroupType.type_id
group.save()

self.project.flags.has_releases = True
self.project.save(update_fields=["flags"])
self.project.update_option("sentry:seer_scanner_automation", True)
self.organization.update_option("sentry:enable_seer_enhanced_alerts", True)

mock_summary = {
"headline": "Custom AI Title",
"whatsWrong": "This is what's wrong with the issue",
"trace": "This is trace information",
"possibleCause": "This is a possible cause",
}
patch_path = "sentry.integrations.utils.issue_summary_for_alerts.get_issue_summary"
serializer_path = "sentry.api.serializers.models.event.EventSerializer.serialize"
serializer_mock = Mock(return_value={})

with (
patch(patch_path) as mock_get_summary,
patch(serializer_path, serializer_mock),
):
mock_get_summary.return_value = (mock_summary, 200)

blocks = SlackIssuesMessageBuilder(group).build()

title_block = blocks["blocks"][0]["text"]["text"]
assert "IntegrationError" in title_block
assert "Custom AI Title" not in title_block

found_initial_guess = False
for block in blocks["blocks"]:
if block.get("type") == "context":
elements = block.get("elements", [])
for element in elements:
if "Initial Guess" in element.get("text", ""):
found_initial_guess = True
assert "This is a possible cause" in element["text"]
break

assert found_initial_guess, "Initial Guess context block not found"
assert blocks["blocks"][-1]["type"] != "divider"

@with_feature("organizations:slack-compact-alerts")
def test_compact_alerts_context_includes_suggested_assignees(self) -> None:
"""
Test that with compact alerts, suggested assignees are included in the context block
rather than in a separate block.
"""
event = self.store_event(
data={
"event_id": "a" * 32,
"message": "Hello world",
"fingerprint": ["group-1"],
"level": "error",
"stacktrace": {"frames": [{"filename": "foo.py"}]},
},
project_id=self.project.id,
)
assert event.group
group = event.group

# Set up ownership to create suggested assignees
rule = Rule(Matcher("path", "*"), [Owner("team", self.team.slug)])
ProjectOwnership.objects.create(project_id=self.project.id, schema=dump_schema([rule]))

blocks = SlackIssuesMessageBuilder(group, event).build()

found_suggested_in_context = False
found_old_suggested_assignees = False
for block in blocks["blocks"]:
if block.get("type") == "context":
elements = block.get("elements", [])
for element in elements:
text = element.get("text", "")
if "Suggested:" in text:
found_suggested_in_context = True
if "Suggested Assignees:" in text:
found_old_suggested_assignees = True

assert found_suggested_in_context, "Suggested assignees should be in context block"
assert (
not found_old_suggested_assignees
), "Old 'Suggested Assignees:' format should not appear"


class BuildGroupAttachmentReplaysTest(TestCase):
@patch("sentry.models.group.Group.has_replays")
Expand Down
Loading
Loading