Skip to content
Merged
2 changes: 2 additions & 0 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,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
119 changes: 86 additions & 33 deletions src/sentry/integrations/slack/message_builder/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
MAX_BLOCK_TEXT_LENGTH = 256
USER_FEEDBACK_MAX_BLOCK_TEXT_LENGTH = 1500
MAX_SUMMARY_HEADLINE_LENGTH = 50
MAX_SUGGESTED_ASSIGNEES = 3


def get_group_users_count(group: Group, rules: list[Rule] | None = None) -> int:
Expand Down Expand Up @@ -504,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 @@ -514,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 @@ -537,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 @@ -570,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 @@ -588,6 +600,7 @@ 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:
Expand Down Expand Up @@ -638,6 +651,55 @@ def get_footer(self) -> SlackBlock:
else:
return self.get_context_block(text=footer, timestamp=timestamp)

def build_description_block(self, description_text: str) -> SlackBlock | None:
# Use issue summary if available (and not flagged for compact alerts), otherwise use the default text
summary_text: str | None = self.get_issue_summary_text()
if summary_text and not self._is_compact:
return self.get_text_block(summary_text, small=True)

text = description_text.lstrip(" ")
# XXX(CEO): sometimes text is " " and slack will error if we pass an empty string (now "")
return self.get_text_block(text) if text else None

def build_group_context_block(self, suggested_assignees: list[str]) -> SlackBlock | None:
"""Combine stats (events, users, state, first seen) with suggested assignees in one context block."""
if not self._is_compact:
# add event count, user count, substate, first seen
context = get_context(self.group, self.rules)
if context:
return self.get_context_block(context)
return None

context_text = get_context(self.group, self.rules)

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

context_text = context_text.strip()
if not context_text:
return None

return self.get_context_block(context_text)

def build_pre_footer_context_blocks(self, suggested_assignees: list[str]) -> list[SlackBlock]:
blocks = []
summary_text: str | None = self.get_issue_summary_text()
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))

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))

return blocks

def build(self, notification_uuid: str | None = None) -> SlackBlock:
self.issue_summary = fetch_issue_summary(self.group)

Expand Down Expand Up @@ -719,17 +781,8 @@ 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():
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))
if description_block := self.build_description_block(text):
blocks.append(description_block)

# set up block id
block_id = {"issue": self.group.id}
Expand All @@ -741,18 +794,28 @@ 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 group_context_block := self.build_group_context_block(
suggested_assignees=suggested_assignees
):
blocks.append(group_context_block)

# 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 +850,8 @@ 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:
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 pre_footer_context_block := self.build_pre_footer_context_blocks(suggested_assignees):
blocks.extend(pre_footer_context_block)

# add notes
if self.notes:
Expand All @@ -808,7 +860,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