From ee67e282faa9a223d6ed4a444775a4b4c30844e3 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Tue, 21 Apr 2026 15:52:28 -0400 Subject: [PATCH 1/4] fix(slack): Prompt unlinked users to link identity for dashboards URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add LinkType.DASHBOARDS to the identity-prompt feature_flag map so users without a linked Slack identity get the "Link your Slack identity to Sentry" prompt when they paste a dashboard widget URL — matching the existing behavior for discover and explore URLs. The prompt continues to gate on the associated feature flag (organizations:dashboards-widget-unfurl), so orgs without the feature see no prompt. Also make the halt message generic instead of hardcoded to "Discover link". Refs DAIN-1569 Co-Authored-By: Claude --- src/sentry/integrations/slack/webhooks/event.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index 44439ecbc195..347b437cd5e4 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -225,6 +225,7 @@ def _get_unfurlable_links( feature_flag = { LinkType.DISCOVER: "organizations:discover-basic", LinkType.EXPLORE: "organizations:data-browsing-widget-unfurl", + LinkType.DASHBOARDS: "organizations:dashboards-widget-unfurl", }.get(link_type) if ( @@ -244,7 +245,9 @@ def _get_unfurlable_links( sentry_sdk.capture_exception(e) self.prompt_link(slack_request) - lifecycle.record_halt("Discover link requires identity", extra={"url": url}) + lifecycle.record_halt( + f"{link_type.value} link requires identity", extra={"url": url} + ) return {} # Don't unfurl the same thing multiple times From be41f27f39c421d8f911bc500d327788c4dab404 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Tue, 21 Apr 2026 15:59:47 -0400 Subject: [PATCH 2/4] fix(slack): Make identity-prompt text generic across link types The prompt text hardcoded "unfurl Discover charts" but the same prompt now fires for explore and dashboards URLs too. Drop the Discover-specific wording. Co-Authored-By: Claude --- src/sentry/integrations/slack/message_builder/prompt.py | 2 +- src/sentry/integrations/slack/webhooks/event.py | 2 +- .../slack/webhooks/events/test_discover_link_shared.py | 5 +---- .../slack/webhooks/events/test_explore_link_shared.py | 5 +---- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/sentry/integrations/slack/message_builder/prompt.py b/src/sentry/integrations/slack/message_builder/prompt.py index d7436dc21373..02421ccecc65 100644 --- a/src/sentry/integrations/slack/message_builder/prompt.py +++ b/src/sentry/integrations/slack/message_builder/prompt.py @@ -4,7 +4,7 @@ from .base.block import BlockSlackMessageBuilder -LINK_IDENTITY_MESSAGE = "Link your Slack identity to Sentry to unfurl Discover charts." +LINK_IDENTITY_MESSAGE = "Link your Slack identity to Sentry to unfurl charts." class SlackPromptLinkMessageBuilder(BlockSlackMessageBuilder): diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index 347b437cd5e4..7a334bf96546 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -128,7 +128,7 @@ def prompt_link(self, slack_request: SlackDMRequest) -> None: payload = { "channel": slack_request.channel_id, "user": slack_request.user_id, - "text": "Link your Slack identity to Sentry to unfurl Discover charts.", + "text": "Link your Slack identity to Sentry to unfurl charts.", **SlackPromptLinkMessageBuilder(associate_url).as_payload(), } diff --git a/tests/sentry/integrations/slack/webhooks/events/test_discover_link_shared.py b/tests/sentry/integrations/slack/webhooks/events/test_discover_link_shared.py index 2b0788e8ba08..70ba67244a27 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_discover_link_shared.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_discover_link_shared.py @@ -158,10 +158,7 @@ def test_share_discover_links_unlinked_user_sdk(self) -> None: blocks = orjson.loads(data["blocks"]) assert blocks[0]["type"] == "section" - assert ( - blocks[0]["text"]["text"] - == "Link your Slack identity to Sentry to unfurl Discover charts." - ) + assert blocks[0]["text"]["text"] == "Link your Slack identity to Sentry to unfurl charts." assert blocks[1]["type"] == "actions" assert len(blocks[1]["elements"]) == 2 diff --git a/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py b/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py index ec7e6eba104a..92773b5f7cd6 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py @@ -98,10 +98,7 @@ def test_share_explore_links_unlinked_user(self) -> None: blocks = orjson.loads(data["blocks"]) assert blocks[0]["type"] == "section" - assert ( - blocks[0]["text"]["text"] - == "Link your Slack identity to Sentry to unfurl Discover charts." - ) + assert blocks[0]["text"]["text"] == "Link your Slack identity to Sentry to unfurl charts." assert blocks[1]["type"] == "actions" assert len(blocks[1]["elements"]) == 2 From a347306a8a73b1320bc09956e37ee3f81138037f Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Wed, 29 Apr 2026 13:56:44 -0400 Subject: [PATCH 3/4] fix(slack): Validate identity for dashboards link_shared events Without this, `_validate_identity()` is gated on discover/explore links in `SlackEventRequest.validate_integration`, so dashboards-only events never resolve `slack_request.user`. The identity-prompt branch added for dashboards in the previous commit would then fire for every share (linked or not), since `has_identity` stays `False`. Adds `has_dashboard_links` and includes it in the gate so the linked user's identity is resolved and the unfurl proceeds with their access. --- .../integrations/slack/requests/event.py | 10 +- .../events/test_dashboard_link_shared.py | 116 ++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 tests/sentry/integrations/slack/webhooks/events/test_dashboard_link_shared.py diff --git a/src/sentry/integrations/slack/requests/event.py b/src/sentry/integrations/slack/requests/event.py index 6a3e4f31b0c2..f29d1463cb51 100644 --- a/src/sentry/integrations/slack/requests/event.py +++ b/src/sentry/integrations/slack/requests/event.py @@ -40,6 +40,10 @@ def has_explore_links(links: list[str]) -> bool: return any(match_link(link)[0] == LinkType.EXPLORE for link in links) +def has_dashboard_links(links: list[str]) -> bool: + return any(match_link(link)[0] == LinkType.DASHBOARDS for link in links) + + def is_event_challenge(data: Mapping[str, Any]) -> bool: return data.get("type", "") == "url_verification" @@ -378,7 +382,11 @@ def validate_integration(self) -> None: if (self.text in COMMANDS) or ( self.type == "link_shared" - and (has_discover_links(self.links) or has_explore_links(self.links)) + and ( + has_discover_links(self.links) + or has_explore_links(self.links) + or has_dashboard_links(self.links) + ) ): self._validate_identity() diff --git a/tests/sentry/integrations/slack/webhooks/events/test_dashboard_link_shared.py b/tests/sentry/integrations/slack/webhooks/events/test_dashboard_link_shared.py new file mode 100644 index 000000000000..8666e9a178c9 --- /dev/null +++ b/tests/sentry/integrations/slack/webhooks/events/test_dashboard_link_shared.py @@ -0,0 +1,116 @@ +import re +from unittest.mock import Mock, patch + +import orjson +import pytest +from slack_sdk.web import SlackResponse + +from sentry.integrations.slack.unfurl.types import Handler, LinkType, make_type_coercer + +from . import LINK_SHARED_EVENT, BaseEventTest + + +class DashboardLinkSharedEvent(BaseEventTest): + @pytest.fixture(autouse=True) + def mock_chat_postEphemeral(self): + with patch( + "slack_sdk.web.client.WebClient.chat_postEphemeral", + return_value=SlackResponse( + client=None, + http_verb="POST", + api_url="https://slack.com/api/chat.postEphemeral", + req_args={}, + data={"ok": True}, + headers={}, + status_code=200, + ), + ) as self.mock_post: + yield + + @pytest.fixture(autouse=True) + def mock_chat_unfurl(self): + with patch( + "slack_sdk.web.client.WebClient.chat_unfurl", + return_value=SlackResponse( + client=None, + http_verb="POST", + api_url="https://slack.com/api/chat.unfurl", + req_args={}, + data={"ok": True}, + headers={}, + status_code=200, + ), + ) as self.mock_unfurl: + yield + + @patch( + "sentry.integrations.slack.webhooks.event.match_link", + side_effect=[ + (LinkType.DASHBOARDS, {"arg1": "value1"}), + (LinkType.DASHBOARDS, {"arg1", "value2"}), + (LinkType.DASHBOARDS, {"arg1": "value1"}), + ], + ) + @patch("sentry.integrations.slack.requests.event.has_dashboard_links", return_value=True) + @patch( + "sentry.integrations.slack.webhooks.event.link_handlers", + { + LinkType.DASHBOARDS: Handler( + matcher=[re.compile(r"test")], + arg_mapper=make_type_coercer({}), + fn=Mock(return_value={"link1": "unfurl", "link2": "unfurl"}), + ) + }, + ) + def share_dashboard_links_sdk(self, mock_match_link, mock_): + resp = self.post_webhook(event_data=LINK_SHARED_EVENT) + assert resp.status_code == 200, resp.content + return self.mock_unfurl.call_args[1] + + @patch( + "sentry.integrations.slack.webhooks.event.match_link", + side_effect=[ + (LinkType.DASHBOARDS, {"arg1": "value1"}), + (LinkType.DASHBOARDS, {"arg1", "value2"}), + (LinkType.DASHBOARDS, {"arg1": "value1"}), + ], + ) + @patch("sentry.integrations.slack.requests.event.has_dashboard_links", return_value=True) + @patch( + "sentry.integrations.slack.webhooks.event.link_handlers", + { + LinkType.DASHBOARDS: Handler( + matcher=[re.compile(r"test")], + arg_mapper=make_type_coercer({}), + fn=Mock(return_value={"link1": "unfurl", "link2": "unfurl"}), + ) + }, + ) + def share_dashboard_links_ephemeral_sdk(self, mock_match_link, mock_): + resp = self.post_webhook(event_data=LINK_SHARED_EVENT) + assert resp.status_code == 200, resp.content + return self.mock_post.call_args[1] + + def test_share_dashboard_links_unlinked_user(self) -> None: + with self.feature("organizations:dashboards-widget-unfurl"): + data = self.share_dashboard_links_ephemeral_sdk() + + blocks = orjson.loads(data["blocks"]) + + assert blocks[0]["type"] == "section" + assert blocks[0]["text"]["text"] == "Link your Slack identity to Sentry to unfurl charts." + + assert blocks[1]["type"] == "actions" + assert len(blocks[1]["elements"]) == 2 + assert [button["text"]["text"] for button in blocks[1]["elements"]] == ["Link", "Cancel"] + + def test_share_dashboard_links_linked_user(self) -> None: + self.link_identity(slack_user_id=LINK_SHARED_EVENT["user"]) + data = self.share_dashboard_links_sdk() + + unfurls = data["unfurls"] + + # We only have two unfurls since one link was duplicated + assert len(unfurls) == 2 + assert unfurls["link1"] == "unfurl" + assert unfurls["link2"] == "unfurl" From a3bf1e19fda090fe0aa5336ef4e51359b27f05c1 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Wed, 29 Apr 2026 13:57:30 -0400 Subject: [PATCH 4/4] Remove dashboard link_shared test --- .../events/test_dashboard_link_shared.py | 116 ------------------ 1 file changed, 116 deletions(-) delete mode 100644 tests/sentry/integrations/slack/webhooks/events/test_dashboard_link_shared.py diff --git a/tests/sentry/integrations/slack/webhooks/events/test_dashboard_link_shared.py b/tests/sentry/integrations/slack/webhooks/events/test_dashboard_link_shared.py deleted file mode 100644 index 8666e9a178c9..000000000000 --- a/tests/sentry/integrations/slack/webhooks/events/test_dashboard_link_shared.py +++ /dev/null @@ -1,116 +0,0 @@ -import re -from unittest.mock import Mock, patch - -import orjson -import pytest -from slack_sdk.web import SlackResponse - -from sentry.integrations.slack.unfurl.types import Handler, LinkType, make_type_coercer - -from . import LINK_SHARED_EVENT, BaseEventTest - - -class DashboardLinkSharedEvent(BaseEventTest): - @pytest.fixture(autouse=True) - def mock_chat_postEphemeral(self): - with patch( - "slack_sdk.web.client.WebClient.chat_postEphemeral", - return_value=SlackResponse( - client=None, - http_verb="POST", - api_url="https://slack.com/api/chat.postEphemeral", - req_args={}, - data={"ok": True}, - headers={}, - status_code=200, - ), - ) as self.mock_post: - yield - - @pytest.fixture(autouse=True) - def mock_chat_unfurl(self): - with patch( - "slack_sdk.web.client.WebClient.chat_unfurl", - return_value=SlackResponse( - client=None, - http_verb="POST", - api_url="https://slack.com/api/chat.unfurl", - req_args={}, - data={"ok": True}, - headers={}, - status_code=200, - ), - ) as self.mock_unfurl: - yield - - @patch( - "sentry.integrations.slack.webhooks.event.match_link", - side_effect=[ - (LinkType.DASHBOARDS, {"arg1": "value1"}), - (LinkType.DASHBOARDS, {"arg1", "value2"}), - (LinkType.DASHBOARDS, {"arg1": "value1"}), - ], - ) - @patch("sentry.integrations.slack.requests.event.has_dashboard_links", return_value=True) - @patch( - "sentry.integrations.slack.webhooks.event.link_handlers", - { - LinkType.DASHBOARDS: Handler( - matcher=[re.compile(r"test")], - arg_mapper=make_type_coercer({}), - fn=Mock(return_value={"link1": "unfurl", "link2": "unfurl"}), - ) - }, - ) - def share_dashboard_links_sdk(self, mock_match_link, mock_): - resp = self.post_webhook(event_data=LINK_SHARED_EVENT) - assert resp.status_code == 200, resp.content - return self.mock_unfurl.call_args[1] - - @patch( - "sentry.integrations.slack.webhooks.event.match_link", - side_effect=[ - (LinkType.DASHBOARDS, {"arg1": "value1"}), - (LinkType.DASHBOARDS, {"arg1", "value2"}), - (LinkType.DASHBOARDS, {"arg1": "value1"}), - ], - ) - @patch("sentry.integrations.slack.requests.event.has_dashboard_links", return_value=True) - @patch( - "sentry.integrations.slack.webhooks.event.link_handlers", - { - LinkType.DASHBOARDS: Handler( - matcher=[re.compile(r"test")], - arg_mapper=make_type_coercer({}), - fn=Mock(return_value={"link1": "unfurl", "link2": "unfurl"}), - ) - }, - ) - def share_dashboard_links_ephemeral_sdk(self, mock_match_link, mock_): - resp = self.post_webhook(event_data=LINK_SHARED_EVENT) - assert resp.status_code == 200, resp.content - return self.mock_post.call_args[1] - - def test_share_dashboard_links_unlinked_user(self) -> None: - with self.feature("organizations:dashboards-widget-unfurl"): - data = self.share_dashboard_links_ephemeral_sdk() - - blocks = orjson.loads(data["blocks"]) - - assert blocks[0]["type"] == "section" - assert blocks[0]["text"]["text"] == "Link your Slack identity to Sentry to unfurl charts." - - assert blocks[1]["type"] == "actions" - assert len(blocks[1]["elements"]) == 2 - assert [button["text"]["text"] for button in blocks[1]["elements"]] == ["Link", "Cancel"] - - def test_share_dashboard_links_linked_user(self) -> None: - self.link_identity(slack_user_id=LINK_SHARED_EVENT["user"]) - data = self.share_dashboard_links_sdk() - - unfurls = data["unfurls"] - - # We only have two unfurls since one link was duplicated - assert len(unfurls) == 2 - assert unfurls["link1"] == "unfurl" - assert unfurls["link2"] == "unfurl"