From b36dd73726c23d466ec5f280d1197835a183c60d Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Fri, 18 Sep 2026 14:29:15 -0700 Subject: [PATCH 1/9] ref(np): Dispatch provider renderers through a registry Replaces the per-provider `get_renderer` overrides with a `NotificationRendererRegistry` keyed by provider and notification source. Renderers register themselves where they are defined, and `NotificationProvider.get_renderer` resolves the renderer from the notification's source, falling back to the provider's default renderer. Keying on source rather than category lets the Seer renderers be registered directly, replacing the nested source check the Slack provider needed to separate `SeerAgentWriteApprovalSlackRenderer` from `SeerSlackRenderer`. `SlackStagingNotificationProvider` resolves against the Slack key, since it sends Slack payloads under its own provider key. The registry imports renderer modules on first lookup rather than from `AppConfig.ready()`: renderers reach into integration packages whose __init__ imports `sentry.integrations.jira.client`, which reads `system.url-prefix` at module scope before the options cache exists. Also drops the unused `provider_key` attribute from the `NotificationRenderer` protocol. Co-authored-by: Cursor --- .../internal_registered_templates.py | 2 +- .../platform/discord/provider.py | 18 ----- .../platform/discord/renderers/issue.py | 5 +- .../discord/renderers/metric_alert.py | 5 +- .../notifications/platform/email/provider.py | 2 - .../platform/msteams/provider.py | 2 - src/sentry/notifications/platform/provider.py | 11 +-- src/sentry/notifications/platform/registry.py | 73 ++++++++++++++++++- src/sentry/notifications/platform/renderer.py | 3 - src/sentry/notifications/platform/service.py | 2 +- .../notifications/platform/slack/provider.py | 34 ++------- .../platform/slack/renderers/issue.py | 5 +- .../platform/slack/renderers/metric_alert.py | 5 +- .../platform/slack/renderers/seer.py | 12 +++ .../renderers/seer_agent_write_approval.py | 7 +- .../integrations/slack/test_integration.py | 5 +- .../platform/discord/renderers/test_issue.py | 22 ++---- .../discord/renderers/test_metric_alert.py | 16 ++-- .../platform/discord/test_provider.py | 21 ++---- .../platform/msteams/test_provider.py | 21 ++---- .../platform/slack/renderers/test_issue.py | 21 ++---- .../slack/renderers/test_metric_alert.py | 16 ++-- .../platform/slack/renderers/test_seer.py | 6 +- .../platform/slack/test_provider.py | 29 +++++++- .../notifications/platform/test_provider.py | 5 +- 25 files changed, 172 insertions(+), 176 deletions(-) diff --git a/src/sentry/notifications/platform/api/endpoints/internal_registered_templates.py b/src/sentry/notifications/platform/api/endpoints/internal_registered_templates.py index c461787cbae7..8e22f07efe59 100644 --- a/src/sentry/notifications/platform/api/endpoints/internal_registered_templates.py +++ b/src/sentry/notifications/platform/api/endpoints/internal_registered_templates.py @@ -104,7 +104,7 @@ def serialize_slack_preview[T: NotificationData]( ) -> dict[str, Any]: data = template.example_data rendered_template = template.render_example() - renderer = SlackNotificationProvider.get_renderer(data=data, category=template.category) + renderer = SlackNotificationProvider.get_renderer(data=data) message = renderer.render(data=data, rendered_template=rendered_template) serialized_blocks = [] diff --git a/src/sentry/notifications/platform/discord/provider.py b/src/sentry/notifications/platform/discord/provider.py index a9e29683ef3b..b92e23fa7a56 100644 --- a/src/sentry/notifications/platform/discord/provider.py +++ b/src/sentry/notifications/platform/discord/provider.py @@ -18,7 +18,6 @@ from sentry.notifications.platform.threading import ThreadContext from sentry.notifications.platform.types import ( LinkTextBlock, - NotificationCategory, NotificationData, NotificationProviderKey, NotificationRenderedTemplate, @@ -40,8 +39,6 @@ class DiscordRenderer(NotificationRenderer[DiscordRenderable]): - provider_key = NotificationProviderKey.DISCORD - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate @@ -152,21 +149,6 @@ def is_available(cls, *, organization: RpcOrganizationSummary | None = None) -> # TODO(ecosystem): Check for the integration, maybe a feature as well return False - @classmethod - def get_renderer( - cls, *, data: NotificationData, category: NotificationCategory - ) -> type[NotificationRenderer[DiscordRenderable]]: - from sentry.notifications.platform.discord.renderers.issue import IssueDiscordRenderer - from sentry.notifications.platform.discord.renderers.metric_alert import ( - DiscordMetricAlertRenderer, - ) - - if category == NotificationCategory.ISSUE: - return IssueDiscordRenderer - if category == NotificationCategory.METRIC_ALERT: - return DiscordMetricAlertRenderer - return cls.default_renderer - @classmethod def send( cls, diff --git a/src/sentry/notifications/platform/discord/renderers/issue.py b/src/sentry/notifications/platform/discord/renderers/issue.py index 855f2a163cec..47037f32f5cf 100644 --- a/src/sentry/notifications/platform/discord/renderers/issue.py +++ b/src/sentry/notifications/platform/discord/renderers/issue.py @@ -4,6 +4,7 @@ from sentry.integrations.discord.message_builder.issues import DiscordIssuesMessageBuilder from sentry.models.group import Group from sentry.notifications.platform.discord.provider import DiscordRenderable +from sentry.notifications.platform.registry import renderer_registry from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.service import NotificationRenderError from sentry.notifications.platform.templates.issue import IssueNotificationData @@ -11,13 +12,13 @@ NotificationData, NotificationProviderKey, NotificationRenderedTemplate, + NotificationSource, ) from sentry.services.eventstore.models import Event +@renderer_registry.register(NotificationProviderKey.DISCORD, NotificationSource.ISSUE) class IssueDiscordRenderer(NotificationRenderer[DiscordRenderable]): - provider_key = NotificationProviderKey.DISCORD - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate diff --git a/src/sentry/notifications/platform/discord/renderers/metric_alert.py b/src/sentry/notifications/platform/discord/renderers/metric_alert.py index 4b6ab9127693..9454da057806 100644 --- a/src/sentry/notifications/platform/discord/renderers/metric_alert.py +++ b/src/sentry/notifications/platform/discord/renderers/metric_alert.py @@ -8,18 +8,19 @@ from sentry.integrations.discord.message_builder.metric_alerts import get_started_at from sentry.integrations.metric_alerts import get_status_text from sentry.notifications.platform.discord.provider import DiscordRenderable +from sentry.notifications.platform.registry import renderer_registry from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData from sentry.notifications.platform.types import ( NotificationData, NotificationProviderKey, NotificationRenderedTemplate, + NotificationSource, ) +@renderer_registry.register(NotificationProviderKey.DISCORD, NotificationSource.METRIC_ALERT) class DiscordMetricAlertRenderer(NotificationRenderer[DiscordRenderable]): - provider_key = NotificationProviderKey.DISCORD - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate diff --git a/src/sentry/notifications/platform/email/provider.py b/src/sentry/notifications/platform/email/provider.py index 14db78280d64..0e108f7e9e1a 100644 --- a/src/sentry/notifications/platform/email/provider.py +++ b/src/sentry/notifications/platform/email/provider.py @@ -38,8 +38,6 @@ class EmailRenderer(NotificationRenderer[EmailRenderable]): - provider_key = NotificationProviderKey.EMAIL - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate diff --git a/src/sentry/notifications/platform/msteams/provider.py b/src/sentry/notifications/platform/msteams/provider.py index e4949ec20b8b..6325e6716d4e 100644 --- a/src/sentry/notifications/platform/msteams/provider.py +++ b/src/sentry/notifications/platform/msteams/provider.py @@ -38,8 +38,6 @@ class MSTeamsRenderer(NotificationRenderer[MSTeamsRenderable]): - provider_key = NotificationProviderKey.MSTEAMS - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate diff --git a/src/sentry/notifications/platform/provider.py b/src/sentry/notifications/platform/provider.py index 6a1b15adb451..3210be58aa24 100644 --- a/src/sentry/notifications/platform/provider.py +++ b/src/sentry/notifications/platform/provider.py @@ -8,7 +8,6 @@ from sentry.notifications.platform.target import IntegrationNotificationTarget from sentry.notifications.platform.threading import ThreadContext from sentry.notifications.platform.types import ( - NotificationCategory, NotificationData, NotificationProviderKey, NotificationTarget, @@ -133,15 +132,17 @@ def validate_target(cls, *, target: NotificationTarget) -> None: return @classmethod - def get_renderer( - cls, *, data: NotificationData, category: NotificationCategory - ) -> type[NotificationRenderer[RenderableT]]: + def get_renderer(cls, *, data: NotificationData) -> type[NotificationRenderer[RenderableT]]: """ Returns an instance of a renderer for a given notification, falling back to the default renderer. Override this to method to permit different renderers for the provider, though keep in mind that this may produce inconsistencies between notifications. """ - return cls.default_renderer + # Imported here since the registry imports this module to type its registrations. + from sentry.notifications.platform.registry import renderer_registry + + renderer = renderer_registry.get(provider_key=cls.key, source=data.source) + return renderer or cls.default_renderer @classmethod def is_available(cls, *, organization: RpcOrganizationSummary | None = None) -> bool: diff --git a/src/sentry/notifications/platform/registry.py b/src/sentry/notifications/platform/registry.py index 84d313d4293f..7a13e663ede6 100644 --- a/src/sentry/notifications/platform/registry.py +++ b/src/sentry/notifications/platform/registry.py @@ -1,11 +1,17 @@ from __future__ import annotations +from collections.abc import Callable from typing import Any from sentry.notifications.platform.provider import NotificationProvider -from sentry.notifications.platform.types import NotificationTemplate +from sentry.notifications.platform.renderer import NotificationRenderer +from sentry.notifications.platform.types import ( + NotificationProviderKey, + NotificationSource, + NotificationTemplate, +) from sentry.organizations.services.organization.model import RpcOrganizationSummary -from sentry.utils.registry import Registry +from sentry.utils.registry import AlreadyRegisteredError, Registry class NotificationProviderRegistry(Registry[type[NotificationProvider[Any]]]): @@ -33,5 +39,68 @@ def get_available( ] +class NotificationRendererRegistry: + """ + A registry for renderers which override a provider's default renderer. Renderers are keyed by + the provider they produce output for, and the notification sources they render. + """ + + def __init__(self) -> None: + self.registrations: dict[ + tuple[NotificationProviderKey, NotificationSource], type[NotificationRenderer[Any]] + ] = {} + self._loaded = False + + def _load(self) -> None: + """ + Import the renderer modules so their registrations take effect. Renderers reach into + integration packages, which read options while being imported, so they cannot be loaded + from `AppConfig.ready()` the way providers and templates are. + """ + if self._loaded: + return + self._loaded = True + + import sentry.notifications.platform.discord.renderers.issue # noqa: F401 + import sentry.notifications.platform.discord.renderers.metric_alert # noqa: F401 + import sentry.notifications.platform.slack.renderers.issue # noqa: F401 + import sentry.notifications.platform.slack.renderers.metric_alert # noqa: F401 + import sentry.notifications.platform.slack.renderers.seer # noqa: F401 + import sentry.notifications.platform.slack.renderers.seer_agent_write_approval # noqa: F401 + + def register[RenderableT]( + self, provider_key: NotificationProviderKey, *sources: NotificationSource + ) -> Callable[ + [type[NotificationRenderer[RenderableT]]], type[NotificationRenderer[RenderableT]] + ]: + if not sources: + raise ValueError("At least one notification source is required to register a renderer") + + def inner( + renderer: type[NotificationRenderer[RenderableT]], + ) -> type[NotificationRenderer[RenderableT]]: + for source in sources: + key = (provider_key, source) + if key in self.registrations: + raise AlreadyRegisteredError( + f"A registration already exists for {key}: {self.registrations[key]}" + ) + self.registrations[key] = renderer + return renderer + + return inner + + def get( + self, *, provider_key: NotificationProviderKey, source: NotificationSource + ) -> type[NotificationRenderer[Any]] | None: + """ + Returns the registered renderer for the provider/source pair, or `None` if the provider has + no override and should fall back to its default renderer. + """ + self._load() + return self.registrations.get((provider_key, source)) + + provider_registry = NotificationProviderRegistry() template_registry = Registry[type[NotificationTemplate[Any]]]() +renderer_registry = NotificationRendererRegistry() diff --git a/src/sentry/notifications/platform/renderer.py b/src/sentry/notifications/platform/renderer.py index 9cbea90090bd..b84a890420ea 100644 --- a/src/sentry/notifications/platform/renderer.py +++ b/src/sentry/notifications/platform/renderer.py @@ -2,7 +2,6 @@ from sentry.notifications.platform.types import ( NotificationData, - NotificationProviderKey, NotificationRenderedTemplate, ) @@ -17,8 +16,6 @@ class NotificationRenderer[RenderableT](Protocol): RenderableT is a type that matches the connected provider. """ - provider_key: NotificationProviderKey - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate diff --git a/src/sentry/notifications/platform/service.py b/src/sentry/notifications/platform/service.py index bbe95c0931b1..72aa5d0715a4 100644 --- a/src/sentry/notifications/platform/service.py +++ b/src/sentry/notifications/platform/service.py @@ -171,7 +171,7 @@ def render_template[RenderableT]( provider: type[NotificationProvider[RenderableT]], ) -> RenderableT: rendered_template = template.render(data=data) - renderer = provider.get_renderer(data=data, category=template.category) + renderer = provider.get_renderer(data=data) return renderer.render(data=data, rendered_template=rendered_template) @staticmethod diff --git a/src/sentry/notifications/platform/slack/provider.py b/src/sentry/notifications/platform/slack/provider.py index d441c674c1c9..38c42d0789fe 100644 --- a/src/sentry/notifications/platform/slack/provider.py +++ b/src/sentry/notifications/platform/slack/provider.py @@ -32,13 +32,11 @@ from sentry.notifications.platform.threading import ThreadContext from sentry.notifications.platform.types import ( LinkTextBlock, - NotificationCategory, NotificationData, NotificationProviderKey, NotificationRenderedTemplate, NotificationSection, NotificationSectionType, - NotificationSource, NotificationTarget, NotificationTargetResourceType, NotificationTextBlock, @@ -65,8 +63,6 @@ class SlackRenderable(TypedDict): class SlackRenderer(NotificationRenderer[SlackRenderable]): - provider_key = NotificationProviderKey.SLACK - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate @@ -144,31 +140,6 @@ def is_available(cls, *, organization: RpcOrganizationSummary | None = None) -> # I currently view this as akin to a rollout or feature flag for the registry return False - @classmethod - def get_renderer( - cls, *, data: NotificationData, category: NotificationCategory - ) -> type[NotificationRenderer[SlackRenderable]]: - from sentry.notifications.platform.slack.renderers.issue import ( - IssueSlackRenderer, - ) - from sentry.notifications.platform.slack.renderers.metric_alert import ( - SlackMetricAlertRenderer, - ) - from sentry.notifications.platform.slack.renderers.seer import SeerSlackRenderer - from sentry.notifications.platform.slack.renderers.seer_agent_write_approval import ( - SeerAgentWriteApprovalSlackRenderer, - ) - - if category == NotificationCategory.SEER: - if data.source == NotificationSource.SEER_AGENT_WRITE_APPROVAL: - return SeerAgentWriteApprovalSlackRenderer - return SeerSlackRenderer - if category == NotificationCategory.ISSUE: - return IssueSlackRenderer - if category == NotificationCategory.METRIC_ALERT: - return SlackMetricAlertRenderer - return cls.default_renderer - @classmethod def send( cls, @@ -228,3 +199,8 @@ def _send_with_threading( @provider_registry.register(NotificationProviderKey.SLACK_STAGING) class SlackStagingNotificationProvider(SlackNotificationProvider): key = NotificationProviderKey.SLACK_STAGING + + @classmethod + def get_renderer(cls, *, data: NotificationData) -> type[NotificationRenderer[SlackRenderable]]: + # Staging sends Slack payloads, so it resolves renderers registered against the Slack key. + return SlackNotificationProvider.get_renderer(data=data) diff --git a/src/sentry/notifications/platform/slack/renderers/issue.py b/src/sentry/notifications/platform/slack/renderers/issue.py index 096fd3b73513..0bf5fb099aa7 100644 --- a/src/sentry/notifications/platform/slack/renderers/issue.py +++ b/src/sentry/notifications/platform/slack/renderers/issue.py @@ -2,6 +2,7 @@ from sentry import eventstore from sentry.models.group import Group +from sentry.notifications.platform.registry import renderer_registry from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.templates.issue import IssueNotificationData @@ -9,12 +10,12 @@ NotificationData, NotificationProviderKey, NotificationRenderedTemplate, + NotificationSource, ) +@renderer_registry.register(NotificationProviderKey.SLACK, NotificationSource.ISSUE) class IssueSlackRenderer(NotificationRenderer[SlackRenderable]): - provider_key = NotificationProviderKey.SLACK - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate diff --git a/src/sentry/notifications/platform/slack/renderers/metric_alert.py b/src/sentry/notifications/platform/slack/renderers/metric_alert.py index 099a124d544e..501d0810cc76 100644 --- a/src/sentry/notifications/platform/slack/renderers/metric_alert.py +++ b/src/sentry/notifications/platform/slack/renderers/metric_alert.py @@ -7,6 +7,7 @@ from sentry.integrations.slack.message_builder.incidents import get_started_at from sentry.integrations.slack.message_builder.types import INCIDENT_COLOR_MAPPING from sentry.integrations.slack.utils.escape import escape_slack_text +from sentry.notifications.platform.registry import renderer_registry from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData @@ -14,12 +15,12 @@ NotificationData, NotificationProviderKey, NotificationRenderedTemplate, + NotificationSource, ) +@renderer_registry.register(NotificationProviderKey.SLACK, NotificationSource.METRIC_ALERT) class SlackMetricAlertRenderer(NotificationRenderer[SlackRenderable]): - provider_key = NotificationProviderKey.SLACK - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index 8a6b9c30fe2e..a887254c42d9 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -21,6 +21,7 @@ SectionBlock, ) +from sentry.notifications.platform.registry import renderer_registry from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.templates.seer import ( @@ -32,7 +33,9 @@ ) from sentry.notifications.platform.types import ( NotificationData, + NotificationProviderKey, NotificationRenderedTemplate, + NotificationSource, ) from sentry.seer.autofix.utils import AutofixStoppingPoint, CodingAgentProviderType @@ -85,6 +88,15 @@ class AutofixStageConfig(TypedDict): } +@renderer_registry.register( + NotificationProviderKey.SLACK, + NotificationSource.SEER_AUTOFIX_TRIGGER, + NotificationSource.SEER_AUTOFIX_ERROR, + NotificationSource.SEER_AUTOFIX_SUCCESS, + NotificationSource.SEER_AUTOFIX_UPDATE, + NotificationSource.SEER_AGENT_RESPONSE, + NotificationSource.SEER_AGENT_ERROR, +) class SeerSlackRenderer(NotificationRenderer[SlackRenderable]): @classmethod def render[DataT: NotificationData]( diff --git a/src/sentry/notifications/platform/slack/renderers/seer_agent_write_approval.py b/src/sentry/notifications/platform/slack/renderers/seer_agent_write_approval.py index 9016e8ccc645..c2e652fa51c3 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer_agent_write_approval.py +++ b/src/sentry/notifications/platform/slack/renderers/seer_agent_write_approval.py @@ -3,6 +3,7 @@ from sentry.integrations.slack.message_builder.routing import encode_action_id from sentry.integrations.slack.message_builder.types import SlackAction +from sentry.notifications.platform.registry import renderer_registry from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.templates.seer import SeerAgentWriteApproval @@ -10,12 +11,14 @@ NotificationData, NotificationProviderKey, NotificationRenderedTemplate, + NotificationSource, ) +@renderer_registry.register( + NotificationProviderKey.SLACK, NotificationSource.SEER_AGENT_WRITE_APPROVAL +) class SeerAgentWriteApprovalSlackRenderer(NotificationRenderer[SlackRenderable]): - provider_key = NotificationProviderKey.SLACK - @classmethod def render[DataT: NotificationData]( cls, *, data: DataT, rendered_template: NotificationRenderedTemplate diff --git a/tests/sentry/integrations/slack/test_integration.py b/tests/sentry/integrations/slack/test_integration.py index df2e6e02e7b7..fb96a94f767c 100644 --- a/tests/sentry/integrations/slack/test_integration.py +++ b/tests/sentry/integrations/slack/test_integration.py @@ -21,7 +21,6 @@ from sentry.notifications.platform.slack.provider import SlackNotificationProvider from sentry.notifications.platform.target import IntegrationNotificationTarget from sentry.notifications.platform.types import ( - NotificationCategory, NotificationProviderKey, NotificationTargetResourceType, ) @@ -316,9 +315,7 @@ def setUp(self) -> None: ) data = MockNotification(message="test") rendered_template = MockNotificationTemplate().render(data) - renderer = SlackNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) + renderer = SlackNotificationProvider.get_renderer(data=data) self.slack_renderable = renderer.render(data=data, rendered_template=rendered_template) @patch("sentry.integrations.slack.sdk_client.SlackSdkClient.chat_postMessage") diff --git a/tests/sentry/notifications/platform/discord/renderers/test_issue.py b/tests/sentry/notifications/platform/discord/renderers/test_issue.py index 0a54b939efdb..3fea480a9d8d 100644 --- a/tests/sentry/notifications/platform/discord/renderers/test_issue.py +++ b/tests/sentry/notifications/platform/discord/renderers/test_issue.py @@ -15,12 +15,12 @@ SerializableRuleProxy, ) from sentry.notifications.platform.types import ( - NotificationCategory, NotificationRenderedTemplate, NotificationSource, ) from sentry.services.eventstore.models import Event from sentry.testutils.cases import TestCase +from sentry.testutils.notifications.platform import MockNotification class IssueDiscordRendererTest(TestCase): @@ -133,22 +133,10 @@ def test_provider_returns_issue_renderer(self) -> None: id=1, label="Test Detector", data={}, project_id=self.project.id ), ) - renderer = DiscordNotificationProvider.get_renderer( - data=data, - category=NotificationCategory.ISSUE, - ) + renderer = DiscordNotificationProvider.get_renderer(data=data) assert renderer is IssueDiscordRenderer - def test_provider_returns_default_for_unknown_category(self) -> None: - data = IssueNotificationData( - group_id=self.group.id, - rule=SerializableRuleProxy( - id=1, label="Test Detector", data={}, project_id=self.project.id - ), - tags=["environment", "level"], - ) - renderer = DiscordNotificationProvider.get_renderer( - data=data, - category=NotificationCategory.DEBUG, - ) + def test_provider_returns_default_for_unregistered_source(self) -> None: + data = MockNotification(message="test") + renderer = DiscordNotificationProvider.get_renderer(data=data) assert renderer is DiscordNotificationProvider.default_renderer diff --git a/tests/sentry/notifications/platform/discord/renderers/test_metric_alert.py b/tests/sentry/notifications/platform/discord/renderers/test_metric_alert.py index 18c17d5d7557..a876041861ab 100644 --- a/tests/sentry/notifications/platform/discord/renderers/test_metric_alert.py +++ b/tests/sentry/notifications/platform/discord/renderers/test_metric_alert.py @@ -17,10 +17,10 @@ from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData from sentry.notifications.platform.templates.seer import SeerAutofixError from sentry.notifications.platform.types import ( - NotificationCategory, NotificationRenderedTemplate, ) from sentry.testutils.cases import TestCase +from sentry.testutils.notifications.platform import MockNotification from tests.sentry.notifications.notification_action.test_metric_alert_registry_handlers import ( MetricAlertHandlerBase, ) @@ -62,18 +62,12 @@ def test_render_raises_on_invalid_data_type(self) -> None: class DiscordMetricAlertProviderDispatchTest(TestCase): def test_provider_returns_metric_alert_renderer(self) -> None: data = _make_notification_data() - renderer = DiscordNotificationProvider.get_renderer( - data=data, - category=NotificationCategory.METRIC_ALERT, - ) + renderer = DiscordNotificationProvider.get_renderer(data=data) assert renderer is DiscordMetricAlertRenderer - def test_provider_returns_default_for_unknown_category(self) -> None: - data = _make_notification_data() - renderer = DiscordNotificationProvider.get_renderer( - data=data, - category=NotificationCategory.DEBUG, - ) + def test_provider_returns_default_for_unregistered_source(self) -> None: + data = MockNotification(message="test") + renderer = DiscordNotificationProvider.get_renderer(data=data) assert renderer is DiscordNotificationProvider.default_renderer diff --git a/tests/sentry/notifications/platform/discord/test_provider.py b/tests/sentry/notifications/platform/discord/test_provider.py index 843a4c8f96ec..4a9679a9583d 100644 --- a/tests/sentry/notifications/platform/discord/test_provider.py +++ b/tests/sentry/notifications/platform/discord/test_provider.py @@ -18,7 +18,6 @@ ) from sentry.notifications.platform.target import IntegrationNotificationTarget from sentry.notifications.platform.types import ( - NotificationCategory, NotificationProviderKey, NotificationRenderedAction, NotificationRenderedImage, @@ -55,9 +54,7 @@ def test_default_renderer(self) -> None: data = MockNotification(message="test") template = MockNotificationTemplate() rendered_template = template.render(data) - renderer = DiscordNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) + renderer = DiscordNotificationProvider.get_renderer(data=data) renderable = renderer.render(data=data, rendered_template=rendered_template) @@ -106,9 +103,7 @@ def test_renderer_without_chart(self) -> None: ) data = MockNotification(message="test without chart") - renderer = DiscordNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) + renderer = DiscordNotificationProvider.get_renderer(data=data) renderable = renderer.render(data=data, rendered_template=rendered_template) @@ -130,9 +125,7 @@ def test_renderer_without_footer(self) -> None: ) data = MockNotification(message="test without footer") - renderer = DiscordNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) + renderer = DiscordNotificationProvider.get_renderer(data=data) renderable = renderer.render(data=data, rendered_template=rendered_template) @@ -152,9 +145,7 @@ def test_renderer_without_actions(self) -> None: ) data = MockNotification(message="test without actions") - renderer = DiscordNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) + renderer = DiscordNotificationProvider.get_renderer(data=data) renderable = renderer.render(data=data, rendered_template=rendered_template) @@ -182,9 +173,7 @@ def test_renderer_multiple_actions(self) -> None: ) data = MockNotification(message="test with multiple actions") - renderer = DiscordNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) + renderer = DiscordNotificationProvider.get_renderer(data=data) renderable = renderer.render(data=data, rendered_template=rendered_template) diff --git a/tests/sentry/notifications/platform/msteams/test_provider.py b/tests/sentry/notifications/platform/msteams/test_provider.py index 784213098047..a91c6ab287f7 100644 --- a/tests/sentry/notifications/platform/msteams/test_provider.py +++ b/tests/sentry/notifications/platform/msteams/test_provider.py @@ -16,7 +16,6 @@ from sentry.notifications.platform.provider import SendFailure, SendFailureStatus from sentry.notifications.platform.target import IntegrationNotificationTarget from sentry.notifications.platform.types import ( - NotificationCategory, NotificationProviderKey, NotificationRenderedAction, NotificationRenderedTemplate, @@ -32,9 +31,7 @@ def test_default_renderer(self) -> None: data = MockNotification(message="test") template = MockNotificationTemplate() rendered_template = template.render(data) - renderer = MSTeamsNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) + renderer = MSTeamsNotificationProvider.get_renderer(data=data) renderable = renderer.render(data=data, rendered_template=rendered_template) @@ -108,9 +105,7 @@ def test_renderer_without_chart(self) -> None: footer=base_template.footer, chart=None, # No chart ) - renderer = MSTeamsNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) + renderer = MSTeamsNotificationProvider.get_renderer(data=data) renderable = renderer.render(data=data, rendered_template=rendered_template) @@ -133,9 +128,7 @@ def test_renderer_without_footer(self) -> None: footer=None, # No footer chart=base_template.chart, ) - renderer = MSTeamsNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) + renderer = MSTeamsNotificationProvider.get_renderer(data=data) renderable = renderer.render(data=data, rendered_template=rendered_template) @@ -162,9 +155,7 @@ def test_renderer_without_actions(self) -> None: footer=base_template.footer, chart=base_template.chart, ) - renderer = MSTeamsNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) + renderer = MSTeamsNotificationProvider.get_renderer(data=data) renderable = renderer.render(data=data, rendered_template=rendered_template) @@ -200,9 +191,7 @@ def test_renderer_multiple_actions(self) -> None: footer=None, chart=None, ) - renderer = MSTeamsNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) + renderer = MSTeamsNotificationProvider.get_renderer(data=data) renderable = renderer.render(data=data, rendered_template=rendered_template) diff --git a/tests/sentry/notifications/platform/slack/renderers/test_issue.py b/tests/sentry/notifications/platform/slack/renderers/test_issue.py index 25c14fb3245f..70ea6a6e2b5a 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_issue.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_issue.py @@ -16,11 +16,11 @@ SerializableRuleProxy, ) from sentry.notifications.platform.types import ( - NotificationCategory, NotificationRenderedTemplate, NotificationSource, ) from sentry.testutils.cases import TestCase +from sentry.testutils.notifications.platform import MockNotification from sentry.utils import json from sentry.workflow_engine.models import Action from sentry.workflow_engine.types import ActionInvocation, WorkflowEventData @@ -335,21 +335,10 @@ def test_provider_returns_issue_renderer(self) -> None: id=1, label="Test Detector", data={}, project_id=self.project.id ), ) - renderer = SlackNotificationProvider.get_renderer( - data=data, - category=NotificationCategory.ISSUE, - ) + renderer = SlackNotificationProvider.get_renderer(data=data) assert renderer is IssueSlackRenderer - def test_provider_returns_default_for_unknown_category(self) -> None: - data = IssueNotificationData( - group_id=self.group.id, - rule=SerializableRuleProxy( - id=1, label="Test Detector", data={}, project_id=self.project.id - ), - ) - renderer = SlackNotificationProvider.get_renderer( - data=data, - category=NotificationCategory.DEBUG, - ) + def test_provider_returns_default_for_unregistered_source(self) -> None: + data = MockNotification(message="test") + renderer = SlackNotificationProvider.get_renderer(data=data) assert renderer is SlackNotificationProvider.default_renderer diff --git a/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py index 55647d670411..2113f3acf149 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_metric_alert.py @@ -12,10 +12,10 @@ from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData from sentry.notifications.platform.templates.seer import SeerAutofixError from sentry.notifications.platform.types import ( - NotificationCategory, NotificationRenderedTemplate, ) from sentry.testutils.cases import TestCase +from sentry.testutils.notifications.platform import MockNotification from tests.sentry.notifications.notification_action.test_metric_alert_registry_handlers import ( MetricAlertHandlerBase, ) @@ -57,18 +57,12 @@ def test_render_raises_on_invalid_data_type(self) -> None: class SlackMetricAlertProviderDispatchTest(TestCase): def test_provider_returns_metric_alert_renderer(self) -> None: data = _make_notification_data() - renderer = SlackNotificationProvider.get_renderer( - data=data, - category=NotificationCategory.METRIC_ALERT, - ) + renderer = SlackNotificationProvider.get_renderer(data=data) assert renderer is SlackMetricAlertRenderer - def test_provider_returns_default_for_unknown_category(self) -> None: - data = _make_notification_data() - renderer = SlackNotificationProvider.get_renderer( - data=data, - category=NotificationCategory.DEBUG, - ) + def test_provider_returns_default_for_unregistered_source(self) -> None: + data = MockNotification(message="test") + renderer = SlackNotificationProvider.get_renderer(data=data) assert renderer is SlackNotificationProvider.default_renderer diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index 657ff15ce0ff..e8e9adc935c4 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -28,7 +28,7 @@ SeerAutofixPullRequest, SeerAutofixUpdate, ) -from sentry.notifications.platform.types import NotificationCategory, NotificationRenderedTemplate +from sentry.notifications.platform.types import NotificationRenderedTemplate from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.testutils.cases import TestCase @@ -409,9 +409,7 @@ def test_render_request_and_provider_dispatch(self) -> None: scopes=["org:write"], ) - renderer = SlackNotificationProvider.get_renderer( - data=data, category=NotificationCategory.SEER - ) + renderer = SlackNotificationProvider.get_renderer(data=data) assert renderer is SeerAgentWriteApprovalSlackRenderer renderable = renderer.render(data=data, rendered_template=self.rendered_template) diff --git a/tests/sentry/notifications/platform/slack/test_provider.py b/tests/sentry/notifications/platform/slack/test_provider.py index 3bc57a1f52c2..adb708cf2eaa 100644 --- a/tests/sentry/notifications/platform/slack/test_provider.py +++ b/tests/sentry/notifications/platform/slack/test_provider.py @@ -1,3 +1,4 @@ +from datetime import datetime from unittest.mock import Mock, patch import pytest @@ -10,6 +11,8 @@ ) from slack_sdk.web import SlackResponse +from sentry.incidents.models.incident import IncidentStatus +from sentry.incidents.typings.metric_detector import OpenPeriodContext from sentry.integrations.types import IntegrationProviderSlug from sentry.notifications.models.notificationthread import NotificationThread from sentry.notifications.platform.provider import ( @@ -21,14 +24,16 @@ from sentry.notifications.platform.slack.provider import ( SlackNotificationProvider, SlackRenderable, + SlackStagingNotificationProvider, ) +from sentry.notifications.platform.slack.renderers.metric_alert import SlackMetricAlertRenderer from sentry.notifications.platform.target import ( GenericNotificationTarget, IntegrationNotificationTarget, ) +from sentry.notifications.platform.templates.metric_alert import MetricAlertNotificationData from sentry.notifications.platform.threading import ThreadContext, ThreadKey from sentry.notifications.platform.types import ( - NotificationCategory, NotificationProviderKey, NotificationSource, NotificationTargetResourceType, @@ -42,9 +47,7 @@ def test_default_renderer(self) -> None: data = MockNotification(message="test") template = MockNotificationTemplate() rendered_template = template.render(data) - renderer = SlackNotificationProvider.get_renderer( - data=data, category=NotificationCategory.DEBUG - ) + renderer = SlackNotificationProvider.get_renderer(data=data) rendererable = renderer.render(data=data, rendered_template=rendered_template) rendererable_dict = [block.to_dict() for block in rendererable.get("blocks", [])] @@ -113,6 +116,24 @@ def test_is_available(self) -> None: assert SlackNotificationProvider.is_available() is False assert SlackNotificationProvider.is_available(organization=self.organization) is False + def test_staging_resolves_slack_renderers(self) -> None: + data = MetricAlertNotificationData( + group_id=1, + organization_id=self.organization.id, + notification_uuid="test-uuid", + action_id=1, + open_period_context=OpenPeriodContext(id=1, date_started=datetime(2024, 1, 1)), + new_status=IncidentStatus.CRITICAL.value, + title="Critical: Example Alert", + title_link="https://sentry.io", + text="123 events in the last 5 minutes", + ) + assert ( + SlackStagingNotificationProvider.get_renderer(data=data) + is SlackNotificationProvider.get_renderer(data=data) + is SlackMetricAlertRenderer + ) + class SlackNotificationProviderSendTest(TestCase): def setUp(self) -> None: diff --git a/tests/sentry/notifications/platform/test_provider.py b/tests/sentry/notifications/platform/test_provider.py index 3b2511f456ea..82474fc47c67 100644 --- a/tests/sentry/notifications/platform/test_provider.py +++ b/tests/sentry/notifications/platform/test_provider.py @@ -9,7 +9,6 @@ IntegrationNotificationTarget, ) from sentry.notifications.platform.types import ( - NotificationCategory, NotificationProviderKey, NotificationTargetResourceType, ) @@ -38,9 +37,7 @@ def test_all_registrants_follow_protocol(self) -> None: for resource_type in provider.target_resource_types: assert resource_type in NotificationTargetResourceType # Ensures the default renderer links back to its connected provider key - assert provider.default_renderer == provider.get_renderer( - data=self.data, category=NotificationCategory.DEBUG - ) + assert provider.default_renderer == provider.get_renderer(data=self.data) assert isinstance(provider.is_available(), bool) assert isinstance( provider.is_available( From 4ac87b48f8c666aebbfcccb1e177d7728f94d211 Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Fri, 18 Sep 2026 14:43:25 -0700 Subject: [PATCH 2/9] separate renderer_key when needed --- src/sentry/notifications/platform/provider.py | 6 +++++- src/sentry/notifications/platform/slack/provider.py | 6 +----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/sentry/notifications/platform/provider.py b/src/sentry/notifications/platform/provider.py index 3210be58aa24..b5e5c3d7f183 100644 --- a/src/sentry/notifications/platform/provider.py +++ b/src/sentry/notifications/platform/provider.py @@ -105,6 +105,8 @@ class NotificationProvider[RenderableT](Protocol): """ key: NotificationProviderKey + renderer_key: NotificationProviderKey | None = None + """The key renderers are registered against, when it differs from `key`.""" default_renderer: type[NotificationRenderer[RenderableT]] target_class: type[NotificationTarget] target_resource_types: list[NotificationTargetResourceType] @@ -141,7 +143,9 @@ def get_renderer(cls, *, data: NotificationData) -> type[NotificationRenderer[Re # Imported here since the registry imports this module to type its registrations. from sentry.notifications.platform.registry import renderer_registry - renderer = renderer_registry.get(provider_key=cls.key, source=data.source) + renderer = renderer_registry.get( + provider_key=cls.renderer_key or cls.key, source=data.source + ) return renderer or cls.default_renderer @classmethod diff --git a/src/sentry/notifications/platform/slack/provider.py b/src/sentry/notifications/platform/slack/provider.py index 38c42d0789fe..ad0663764708 100644 --- a/src/sentry/notifications/platform/slack/provider.py +++ b/src/sentry/notifications/platform/slack/provider.py @@ -199,8 +199,4 @@ def _send_with_threading( @provider_registry.register(NotificationProviderKey.SLACK_STAGING) class SlackStagingNotificationProvider(SlackNotificationProvider): key = NotificationProviderKey.SLACK_STAGING - - @classmethod - def get_renderer(cls, *, data: NotificationData) -> type[NotificationRenderer[SlackRenderable]]: - # Staging sends Slack payloads, so it resolves renderers registered against the Slack key. - return SlackNotificationProvider.get_renderer(data=data) + renderer_key = NotificationProviderKey.SLACK From a67131259a5a461e3291ef7964a18573df100f32 Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Fri, 18 Sep 2026 15:25:33 -0700 Subject: [PATCH 3/9] ref(np): Take renderer sources as a list Matches how other registration decorators in the codebase accept a set of values, e.g. AlertRuleTriggerAction.register_type's supported_target_types. Co-authored-by: Cursor --- .../platform/discord/renderers/issue.py | 2 +- .../platform/discord/renderers/metric_alert.py | 4 +++- src/sentry/notifications/platform/registry.py | 4 ++-- .../platform/slack/renderers/issue.py | 2 +- .../platform/slack/renderers/metric_alert.py | 4 +++- .../notifications/platform/slack/renderers/seer.py | 14 ++++++++------ .../slack/renderers/seer_agent_write_approval.py | 2 +- 7 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/sentry/notifications/platform/discord/renderers/issue.py b/src/sentry/notifications/platform/discord/renderers/issue.py index 47037f32f5cf..6b80b4b62974 100644 --- a/src/sentry/notifications/platform/discord/renderers/issue.py +++ b/src/sentry/notifications/platform/discord/renderers/issue.py @@ -17,7 +17,7 @@ from sentry.services.eventstore.models import Event -@renderer_registry.register(NotificationProviderKey.DISCORD, NotificationSource.ISSUE) +@renderer_registry.register(NotificationProviderKey.DISCORD, sources=[NotificationSource.ISSUE]) class IssueDiscordRenderer(NotificationRenderer[DiscordRenderable]): @classmethod def render[DataT: NotificationData]( diff --git a/src/sentry/notifications/platform/discord/renderers/metric_alert.py b/src/sentry/notifications/platform/discord/renderers/metric_alert.py index 9454da057806..8c0b07087b98 100644 --- a/src/sentry/notifications/platform/discord/renderers/metric_alert.py +++ b/src/sentry/notifications/platform/discord/renderers/metric_alert.py @@ -19,7 +19,9 @@ ) -@renderer_registry.register(NotificationProviderKey.DISCORD, NotificationSource.METRIC_ALERT) +@renderer_registry.register( + NotificationProviderKey.DISCORD, sources=[NotificationSource.METRIC_ALERT] +) class DiscordMetricAlertRenderer(NotificationRenderer[DiscordRenderable]): @classmethod def render[DataT: NotificationData]( diff --git a/src/sentry/notifications/platform/registry.py b/src/sentry/notifications/platform/registry.py index 7a13e663ede6..25623cfd014c 100644 --- a/src/sentry/notifications/platform/registry.py +++ b/src/sentry/notifications/platform/registry.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Sequence from typing import Any from sentry.notifications.platform.provider import NotificationProvider @@ -69,7 +69,7 @@ def _load(self) -> None: import sentry.notifications.platform.slack.renderers.seer_agent_write_approval # noqa: F401 def register[RenderableT]( - self, provider_key: NotificationProviderKey, *sources: NotificationSource + self, provider_key: NotificationProviderKey, sources: Sequence[NotificationSource] ) -> Callable[ [type[NotificationRenderer[RenderableT]]], type[NotificationRenderer[RenderableT]] ]: diff --git a/src/sentry/notifications/platform/slack/renderers/issue.py b/src/sentry/notifications/platform/slack/renderers/issue.py index 0bf5fb099aa7..76642c29fb1f 100644 --- a/src/sentry/notifications/platform/slack/renderers/issue.py +++ b/src/sentry/notifications/platform/slack/renderers/issue.py @@ -14,7 +14,7 @@ ) -@renderer_registry.register(NotificationProviderKey.SLACK, NotificationSource.ISSUE) +@renderer_registry.register(NotificationProviderKey.SLACK, sources=[NotificationSource.ISSUE]) class IssueSlackRenderer(NotificationRenderer[SlackRenderable]): @classmethod def render[DataT: NotificationData]( diff --git a/src/sentry/notifications/platform/slack/renderers/metric_alert.py b/src/sentry/notifications/platform/slack/renderers/metric_alert.py index 501d0810cc76..1a60a55b53e7 100644 --- a/src/sentry/notifications/platform/slack/renderers/metric_alert.py +++ b/src/sentry/notifications/platform/slack/renderers/metric_alert.py @@ -19,7 +19,9 @@ ) -@renderer_registry.register(NotificationProviderKey.SLACK, NotificationSource.METRIC_ALERT) +@renderer_registry.register( + NotificationProviderKey.SLACK, sources=[NotificationSource.METRIC_ALERT] +) class SlackMetricAlertRenderer(NotificationRenderer[SlackRenderable]): @classmethod def render[DataT: NotificationData]( diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index a887254c42d9..66c567d2653f 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -90,12 +90,14 @@ class AutofixStageConfig(TypedDict): @renderer_registry.register( NotificationProviderKey.SLACK, - NotificationSource.SEER_AUTOFIX_TRIGGER, - NotificationSource.SEER_AUTOFIX_ERROR, - NotificationSource.SEER_AUTOFIX_SUCCESS, - NotificationSource.SEER_AUTOFIX_UPDATE, - NotificationSource.SEER_AGENT_RESPONSE, - NotificationSource.SEER_AGENT_ERROR, + sources=[ + NotificationSource.SEER_AUTOFIX_TRIGGER, + NotificationSource.SEER_AUTOFIX_ERROR, + NotificationSource.SEER_AUTOFIX_SUCCESS, + NotificationSource.SEER_AUTOFIX_UPDATE, + NotificationSource.SEER_AGENT_RESPONSE, + NotificationSource.SEER_AGENT_ERROR, + ], ) class SeerSlackRenderer(NotificationRenderer[SlackRenderable]): @classmethod diff --git a/src/sentry/notifications/platform/slack/renderers/seer_agent_write_approval.py b/src/sentry/notifications/platform/slack/renderers/seer_agent_write_approval.py index c2e652fa51c3..7a62987f0252 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer_agent_write_approval.py +++ b/src/sentry/notifications/platform/slack/renderers/seer_agent_write_approval.py @@ -16,7 +16,7 @@ @renderer_registry.register( - NotificationProviderKey.SLACK, NotificationSource.SEER_AGENT_WRITE_APPROVAL + NotificationProviderKey.SLACK, sources=[NotificationSource.SEER_AGENT_WRITE_APPROVAL] ) class SeerAgentWriteApprovalSlackRenderer(NotificationRenderer[SlackRenderable]): @classmethod From f473bdd294976ff2e5f90049c12b454ba56bbc77 Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Mon, 21 Sep 2026 09:14:20 -0700 Subject: [PATCH 4/9] set loaded after --- src/sentry/notifications/platform/registry.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/sentry/notifications/platform/registry.py b/src/sentry/notifications/platform/registry.py index 25623cfd014c..e88c6ad91ce2 100644 --- a/src/sentry/notifications/platform/registry.py +++ b/src/sentry/notifications/platform/registry.py @@ -59,7 +59,6 @@ def _load(self) -> None: """ if self._loaded: return - self._loaded = True import sentry.notifications.platform.discord.renderers.issue # noqa: F401 import sentry.notifications.platform.discord.renderers.metric_alert # noqa: F401 @@ -68,6 +67,8 @@ def _load(self) -> None: import sentry.notifications.platform.slack.renderers.seer # noqa: F401 import sentry.notifications.platform.slack.renderers.seer_agent_write_approval # noqa: F401 + self._loaded = True + def register[RenderableT]( self, provider_key: NotificationProviderKey, sources: Sequence[NotificationSource] ) -> Callable[ From 5eecb42e82cfabdb8b2eb42721bfce33e1a48d30 Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Mon, 21 Sep 2026 15:28:12 -0700 Subject: [PATCH 5/9] no more lazy loading --- src/sentry/notifications/apps.py | 6 +++++ src/sentry/notifications/platform/registry.py | 20 ---------------- tests/sentry/notifications/test_apps.py | 23 ++++++++++++++++++- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/sentry/notifications/apps.py b/src/sentry/notifications/apps.py index 731ba25d3a3e..b96c5a01f5be 100644 --- a/src/sentry/notifications/apps.py +++ b/src/sentry/notifications/apps.py @@ -7,7 +7,13 @@ class Config(AppConfig): def ready(self) -> None: # Imports to populate registries import sentry.notifications.platform.discord.provider # noqa: F401 + import sentry.notifications.platform.discord.renderers.issue # noqa: F401 + import sentry.notifications.platform.discord.renderers.metric_alert # noqa: F401 import sentry.notifications.platform.email.provider # noqa: F401 import sentry.notifications.platform.msteams.provider # noqa: F401 import sentry.notifications.platform.slack.provider # noqa: F401 + import sentry.notifications.platform.slack.renderers.issue # noqa: F401 + import sentry.notifications.platform.slack.renderers.metric_alert # noqa: F401 + import sentry.notifications.platform.slack.renderers.seer # noqa: F401 + import sentry.notifications.platform.slack.renderers.seer_agent_write_approval # noqa: F401 import sentry.notifications.platform.templates # noqa: F401 diff --git a/src/sentry/notifications/platform/registry.py b/src/sentry/notifications/platform/registry.py index e88c6ad91ce2..426adb419b9f 100644 --- a/src/sentry/notifications/platform/registry.py +++ b/src/sentry/notifications/platform/registry.py @@ -49,25 +49,6 @@ def __init__(self) -> None: self.registrations: dict[ tuple[NotificationProviderKey, NotificationSource], type[NotificationRenderer[Any]] ] = {} - self._loaded = False - - def _load(self) -> None: - """ - Import the renderer modules so their registrations take effect. Renderers reach into - integration packages, which read options while being imported, so they cannot be loaded - from `AppConfig.ready()` the way providers and templates are. - """ - if self._loaded: - return - - import sentry.notifications.platform.discord.renderers.issue # noqa: F401 - import sentry.notifications.platform.discord.renderers.metric_alert # noqa: F401 - import sentry.notifications.platform.slack.renderers.issue # noqa: F401 - import sentry.notifications.platform.slack.renderers.metric_alert # noqa: F401 - import sentry.notifications.platform.slack.renderers.seer # noqa: F401 - import sentry.notifications.platform.slack.renderers.seer_agent_write_approval # noqa: F401 - - self._loaded = True def register[RenderableT]( self, provider_key: NotificationProviderKey, sources: Sequence[NotificationSource] @@ -98,7 +79,6 @@ def get( Returns the registered renderer for the provider/source pair, or `None` if the provider has no override and should fall back to its default renderer. """ - self._load() return self.registrations.get((provider_key, source)) diff --git a/tests/sentry/notifications/test_apps.py b/tests/sentry/notifications/test_apps.py index 578d88721373..d6ee06825f96 100644 --- a/tests/sentry/notifications/test_apps.py +++ b/tests/sentry/notifications/test_apps.py @@ -1,5 +1,5 @@ from sentry.integrations.types import ExternalProviders -from sentry.notifications.platform.types import NotificationProviderKey +from sentry.notifications.platform.types import NotificationProviderKey, NotificationSource from sentry.testutils.cases import TestCase @@ -30,3 +30,24 @@ def test_registers_platform_providers(self) -> None: assert provider_registry.get(NotificationProviderKey.MSTEAMS) is not None assert provider_registry.get(NotificationProviderKey.SLACK) is not None assert provider_registry.get(NotificationProviderKey.SLACK_STAGING) is not None + + def test_registers_platform_renderers(self) -> None: + """ + If this test is failing for you, ensure the module holding your `@renderer_registry.register` + decorator is imported by `sentry.notifications.apps.Config.ready`. + """ + from sentry.notifications.platform.registry import renderer_registry + + assert set(renderer_registry.registrations) == { + (NotificationProviderKey.DISCORD, NotificationSource.ISSUE), + (NotificationProviderKey.DISCORD, NotificationSource.METRIC_ALERT), + (NotificationProviderKey.SLACK, NotificationSource.ISSUE), + (NotificationProviderKey.SLACK, NotificationSource.METRIC_ALERT), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AGENT_ERROR), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AGENT_RESPONSE), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AGENT_WRITE_APPROVAL), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AUTOFIX_ERROR), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AUTOFIX_SUCCESS), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AUTOFIX_TRIGGER), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AUTOFIX_UPDATE), + } From 02b3ff9358d6e89f4635fcf0a53c79900d4a5a5b Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Mon, 21 Sep 2026 15:32:23 -0700 Subject: [PATCH 6/9] ref(np): Render default renderers directly in provider tests Co-authored-by: Cursor --- .../platform/discord/test_provider.py | 16 ++++++---------- .../platform/msteams/test_provider.py | 16 ++++++---------- .../platform/slack/test_provider.py | 4 ++-- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/tests/sentry/notifications/platform/discord/test_provider.py b/tests/sentry/notifications/platform/discord/test_provider.py index 4a9679a9583d..94530f776675 100644 --- a/tests/sentry/notifications/platform/discord/test_provider.py +++ b/tests/sentry/notifications/platform/discord/test_provider.py @@ -15,6 +15,7 @@ from sentry.notifications.platform.discord.provider import ( DiscordNotificationProvider, DiscordRenderable, + DiscordRenderer, ) from sentry.notifications.platform.target import IntegrationNotificationTarget from sentry.notifications.platform.types import ( @@ -54,9 +55,8 @@ def test_default_renderer(self) -> None: data = MockNotification(message="test") template = MockNotificationTemplate() rendered_template = template.render(data) - renderer = DiscordNotificationProvider.get_renderer(data=data) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = DiscordRenderer.render(data=data, rendered_template=rendered_template) # Test basic structure assert "content" in renderable @@ -103,9 +103,8 @@ def test_renderer_without_chart(self) -> None: ) data = MockNotification(message="test without chart") - renderer = DiscordNotificationProvider.get_renderer(data=data) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = DiscordRenderer.render(data=data, rendered_template=rendered_template) embed = renderable["embeds"][0] assert "image" not in embed or embed.get("image") is None @@ -125,9 +124,8 @@ def test_renderer_without_footer(self) -> None: ) data = MockNotification(message="test without footer") - renderer = DiscordNotificationProvider.get_renderer(data=data) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = DiscordRenderer.render(data=data, rendered_template=rendered_template) embed = renderable["embeds"][0] assert "footer" not in embed or embed.get("footer") is None @@ -145,9 +143,8 @@ def test_renderer_without_actions(self) -> None: ) data = MockNotification(message="test without actions") - renderer = DiscordNotificationProvider.get_renderer(data=data) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = DiscordRenderer.render(data=data, rendered_template=rendered_template) # Should have no components when no actions components = renderable["components"] @@ -173,9 +170,8 @@ def test_renderer_multiple_actions(self) -> None: ) data = MockNotification(message="test with multiple actions") - renderer = DiscordNotificationProvider.get_renderer(data=data) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = DiscordRenderer.render(data=data, rendered_template=rendered_template) components = renderable["components"] assert len(components) == 1 diff --git a/tests/sentry/notifications/platform/msteams/test_provider.py b/tests/sentry/notifications/platform/msteams/test_provider.py index a91c6ab287f7..6719ae92f4b8 100644 --- a/tests/sentry/notifications/platform/msteams/test_provider.py +++ b/tests/sentry/notifications/platform/msteams/test_provider.py @@ -12,6 +12,7 @@ from sentry.notifications.platform.msteams.provider import ( MSTeamsNotificationProvider, MSTeamsRenderable, + MSTeamsRenderer, ) from sentry.notifications.platform.provider import SendFailure, SendFailureStatus from sentry.notifications.platform.target import IntegrationNotificationTarget @@ -31,9 +32,8 @@ def test_default_renderer(self) -> None: data = MockNotification(message="test") template = MockNotificationTemplate() rendered_template = template.render(data) - renderer = MSTeamsNotificationProvider.get_renderer(data=data) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = MSTeamsRenderer.render(data=data, rendered_template=rendered_template) # Verify the basic structure of the AdaptiveCard assert renderable["type"] == "AdaptiveCard" @@ -105,9 +105,8 @@ def test_renderer_without_chart(self) -> None: footer=base_template.footer, chart=None, # No chart ) - renderer = MSTeamsNotificationProvider.get_renderer(data=data) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = MSTeamsRenderer.render(data=data, rendered_template=rendered_template) body_blocks = renderable["body"] assert len(body_blocks) == 6 # title, 3 body blocks, actions, footer (no chart) @@ -128,9 +127,8 @@ def test_renderer_without_footer(self) -> None: footer=None, # No footer chart=base_template.chart, ) - renderer = MSTeamsNotificationProvider.get_renderer(data=data) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = MSTeamsRenderer.render(data=data, rendered_template=rendered_template) body_blocks = renderable["body"] assert len(body_blocks) == 6 # title, 3 body blocks, actions, chart (no footer) @@ -155,9 +153,8 @@ def test_renderer_without_actions(self) -> None: footer=base_template.footer, chart=base_template.chart, ) - renderer = MSTeamsNotificationProvider.get_renderer(data=data) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = MSTeamsRenderer.render(data=data, rendered_template=rendered_template) body_blocks = renderable["body"] assert len(body_blocks) == 6 # title, 3 body blocks, chart, footer (no actions) @@ -191,9 +188,8 @@ def test_renderer_multiple_actions(self) -> None: footer=None, chart=None, ) - renderer = MSTeamsNotificationProvider.get_renderer(data=data) - renderable = renderer.render(data=data, rendered_template=rendered_template) + renderable = MSTeamsRenderer.render(data=data, rendered_template=rendered_template) body_blocks = renderable["body"] actions_block = body_blocks[4] diff --git a/tests/sentry/notifications/platform/slack/test_provider.py b/tests/sentry/notifications/platform/slack/test_provider.py index adb708cf2eaa..4596267f1cfc 100644 --- a/tests/sentry/notifications/platform/slack/test_provider.py +++ b/tests/sentry/notifications/platform/slack/test_provider.py @@ -24,6 +24,7 @@ from sentry.notifications.platform.slack.provider import ( SlackNotificationProvider, SlackRenderable, + SlackRenderer, SlackStagingNotificationProvider, ) from sentry.notifications.platform.slack.renderers.metric_alert import SlackMetricAlertRenderer @@ -47,9 +48,8 @@ def test_default_renderer(self) -> None: data = MockNotification(message="test") template = MockNotificationTemplate() rendered_template = template.render(data) - renderer = SlackNotificationProvider.get_renderer(data=data) - rendererable = renderer.render(data=data, rendered_template=rendered_template) + rendererable = SlackRenderer.render(data=data, rendered_template=rendered_template) rendererable_dict = [block.to_dict() for block in rendererable.get("blocks", [])] assert rendererable_dict == [ From 6f7ca41e49e64591b3ef70a3c2ffab449bd60405 Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Mon, 21 Sep 2026 16:42:45 -0700 Subject: [PATCH 7/9] ref(jira): Resolve the Connect app key lazily Importing the jira integration read `system.url-prefix` at module scope, which raises when the renderer modules are imported from `AppConfig.ready()`, before the options store cache is bound. Co-authored-by: Cursor --- src/sentry/integrations/jira/__init__.py | 3 +-- src/sentry/integrations/jira/client.py | 10 ++++------ src/sentry/integrations/jira/endpoints/descriptor.py | 5 ++--- src/sentry/integrations/jira_server/client.py | 7 +++---- src/sentry/integrations/utils/jira.py | 10 ++++++++++ .../integrations/jira/test_sentry_issue_details.py | 10 +++++++--- 6 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/sentry/integrations/jira/__init__.py b/src/sentry/integrations/jira/__init__.py index 9c1b12de2af8..e82b234855a1 100644 --- a/src/sentry/integrations/jira/__init__.py +++ b/src/sentry/integrations/jira/__init__.py @@ -1,12 +1,11 @@ from sentry.rules import rules from .actions import JiraCreateTicketAction -from .client import JIRA_KEY, JiraCloudClient +from .client import JiraCloudClient from .handlers import JiraActionHandler # noqa: F401,F403 from .integration import JiraIntegration, JiraIntegrationProvider __all__ = ( - "JIRA_KEY", "JiraCloudClient", "JiraCreateTicketAction", "JiraIntegration", diff --git a/src/sentry/integrations/jira/client.py b/src/sentry/integrations/jira/client.py index 601c288a4791..349b26c081de 100644 --- a/src/sentry/integrations/jira/client.py +++ b/src/sentry/integrations/jira/client.py @@ -1,7 +1,7 @@ import datetime import logging from typing import Any -from urllib.parse import parse_qs, urlparse, urlsplit +from urllib.parse import parse_qs, urlsplit from requests import PreparedRequest @@ -10,14 +10,12 @@ from sentry.integrations.services.integration.model import RpcIntegration from sentry.integrations.types import IntegrationProviderSlug from sentry.integrations.utils.atlassian_connect import get_query_hash -from sentry.integrations.utils.jira import parse_jira_issue_key +from sentry.integrations.utils.jira import get_jira_key, parse_jira_issue_key from sentry.shared_integrations.exceptions import ApiError from sentry.utils import jwt -from sentry.utils.http import absolute_uri logger = logging.getLogger("sentry.integrations.jira") -JIRA_KEY = f"{urlparse(absolute_uri()).hostname}.jira" CUSTOMFIELD_PREFIX = "customfield_" STATUS_SEARCH_PAGE_SIZE = 200 @@ -79,7 +77,7 @@ def finalize_request(self, prepared_request: PreparedRequest): path = path.split("?")[0] now = datetime.datetime.now(datetime.UTC) jwt_payload = { - "iss": JIRA_KEY, + "iss": get_jira_key(), "iat": now, "exp": now + datetime.timedelta(seconds=5 * 60), "qsh": get_query_hash( @@ -219,7 +217,7 @@ def assign_issue(self, key, name_or_account_id): def set_issue_property(self, issue_key, badge_num): module_key = "sentry-issues-glance" - properties_key = f"com.atlassian.jira.issue:{JIRA_KEY}:{module_key}:status" + properties_key = f"com.atlassian.jira.issue:{get_jira_key()}:{module_key}:status" data = {"type": "badge", "value": {"label": badge_num}} return self.put(self.PROPERTIES_URL % (issue_key, properties_key), data=data) diff --git a/src/sentry/integrations/jira/endpoints/descriptor.py b/src/sentry/integrations/jira/endpoints/descriptor.py index 1af2d7b43a3f..2008a331fcf2 100644 --- a/src/sentry/integrations/jira/endpoints/descriptor.py +++ b/src/sentry/integrations/jira/endpoints/descriptor.py @@ -6,11 +6,10 @@ from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import Endpoint, control_silo_endpoint +from sentry.integrations.utils.jira import get_jira_key from sentry.utils.assets import get_frontend_app_asset_url from sentry.utils.http import absolute_uri -from .. import JIRA_KEY - scopes = ["read", "write", "act_as_user"] # For Jira, only approved apps can use the access_email_addresses scope # This scope allows Sentry to use the email endpoint (https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-rest-api-3-user-email-get) @@ -45,7 +44,7 @@ def get(self, request: Request) -> Response: { "name": "Sentry", "description": "Connect your Sentry organization to one or more of your Jira cloud instances. Get started streamlining your bug-squashing workflow by allowing your Sentry and Jira instances to work together.", - "key": JIRA_KEY, + "key": get_jira_key(), "baseUrl": absolute_uri(), "vendor": {"name": "Sentry", "url": "https://sentry.io"}, "authentication": {"type": "jwt"}, diff --git a/src/sentry/integrations/jira_server/client.py b/src/sentry/integrations/jira_server/client.py index 0befa0a4b3a7..8e99c10b5138 100644 --- a/src/sentry/integrations/jira_server/client.py +++ b/src/sentry/integrations/jira_server/client.py @@ -2,7 +2,7 @@ import logging from typing import Any -from urllib.parse import parse_qsl, urlparse +from urllib.parse import parse_qsl from django.urls import reverse from oauthlib.oauth1 import SIGNATURE_RSA @@ -15,7 +15,7 @@ from sentry.integrations.models.integration import Integration from sentry.integrations.services.integration.model import RpcIntegration from sentry.integrations.types import IntegrationProviderSlug -from sentry.integrations.utils.jira import parse_jira_issue_key +from sentry.integrations.utils.jira import get_jira_key, parse_jira_issue_key from sentry.integrations.utils.metrics import ( IntegrationPipelineViewEvent, IntegrationPipelineViewType, @@ -27,7 +27,6 @@ logger = logging.getLogger(__name__) -JIRA_KEY = f"{urlparse(absolute_uri()).hostname}.jira" CUSTOMFIELD_PREFIX = "customfield_" @@ -208,7 +207,7 @@ def assign_issue(self, key, name_or_account_id): def set_issue_property(self, issue_key, badge_num): module_key = "sentry-issues-glance" - properties_key = f"com.atlassian.jira.issue:{JIRA_KEY}:{module_key}:status" + properties_key = f"com.atlassian.jira.issue:{get_jira_key()}:{module_key}:status" data = {"type": "badge", "value": {"label": badge_num}} return self.put(self.PROPERTIES_URL % (issue_key, properties_key), data=data) diff --git a/src/sentry/integrations/utils/jira.py b/src/sentry/integrations/utils/jira.py index e594ce5024a9..2668531ece5f 100644 --- a/src/sentry/integrations/utils/jira.py +++ b/src/sentry/integrations/utils/jira.py @@ -4,6 +4,7 @@ from urllib.parse import parse_qs, urlparse from sentry.integrations.utils.issue_url import get_url_origin +from sentry.utils.http import absolute_uri ISSUE_KEY_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*-\d+$") @@ -13,6 +14,15 @@ _ISSUE_PATH_PARENTS = frozenset({"browse", "issues"}) +def get_jira_key() -> str: + """ + The Atlassian Connect app key, shared by the cloud and server clients. Must match the `key` + served by the Jira descriptor endpoint, and can only be resolved once the options store is + available. + """ + return f"{urlparse(absolute_uri()).hostname}.jira" + + def _path_segments(path: str) -> list[str]: return [segment for segment in path.split("/") if segment] diff --git a/tests/sentry/integrations/jira/test_sentry_issue_details.py b/tests/sentry/integrations/jira/test_sentry_issue_details.py index cfc61c1f5eab..3b860dc8b58b 100644 --- a/tests/sentry/integrations/jira/test_sentry_issue_details.py +++ b/tests/sentry/integrations/jira/test_sentry_issue_details.py @@ -4,11 +4,11 @@ import responses from jwt import ExpiredSignatureError -from sentry.integrations.jira import JIRA_KEY from sentry.integrations.jira.views import UNABLE_TO_VERIFY_INSTALLATION from sentry.integrations.models.external_issue import ExternalIssue from sentry.integrations.models.integration import Integration from sentry.integrations.utils.atlassian_connect import AtlassianConnectValidationError +from sentry.integrations.utils.jira import get_jira_key from sentry.models.group import Group from sentry.models.grouplink import GroupLink from sentry.models.project import Project @@ -80,7 +80,9 @@ def setUp(self) -> None: self.login_as(self.user) - self.properties_key = f"com.atlassian.jira.issue:{JIRA_KEY}:sentry-issues-glance:status" + self.properties_key = ( + f"com.atlassian.jira.issue:{get_jira_key()}:sentry-issues-glance:status" + ) self.properties_url = "https://getsentry.atlassian.net/rest/api/3/issue/%s/properties/%s" @patch( @@ -235,7 +237,9 @@ def setUp(self) -> None: self.login_as(self.user) - self.properties_key = f"com.atlassian.jira.issue:{JIRA_KEY}:sentry-issues-glance:status" + self.properties_key = ( + f"com.atlassian.jira.issue:{get_jira_key()}:sentry-issues-glance:status" + ) self.properties_url = "https://getsentry.atlassian.net/rest/api/3/issue/%s/properties/%s" @patch( From 62b57e3638396bf80b75b8d21f94bab64ca94398 Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Tue, 22 Sep 2026 10:40:43 -0700 Subject: [PATCH 8/9] Revert "ref(jira): Resolve the Connect app key lazily" This reverts commit 75c7b35016294152f0c1f27648ed81bed60e2ff7. --- src/sentry/integrations/jira/__init__.py | 3 ++- src/sentry/integrations/jira/client.py | 10 ++++++---- src/sentry/integrations/jira/endpoints/descriptor.py | 5 +++-- src/sentry/integrations/jira_server/client.py | 7 ++++--- src/sentry/integrations/utils/jira.py | 10 ---------- .../integrations/jira/test_sentry_issue_details.py | 10 +++------- 6 files changed, 18 insertions(+), 27 deletions(-) diff --git a/src/sentry/integrations/jira/__init__.py b/src/sentry/integrations/jira/__init__.py index e82b234855a1..9c1b12de2af8 100644 --- a/src/sentry/integrations/jira/__init__.py +++ b/src/sentry/integrations/jira/__init__.py @@ -1,11 +1,12 @@ from sentry.rules import rules from .actions import JiraCreateTicketAction -from .client import JiraCloudClient +from .client import JIRA_KEY, JiraCloudClient from .handlers import JiraActionHandler # noqa: F401,F403 from .integration import JiraIntegration, JiraIntegrationProvider __all__ = ( + "JIRA_KEY", "JiraCloudClient", "JiraCreateTicketAction", "JiraIntegration", diff --git a/src/sentry/integrations/jira/client.py b/src/sentry/integrations/jira/client.py index 349b26c081de..601c288a4791 100644 --- a/src/sentry/integrations/jira/client.py +++ b/src/sentry/integrations/jira/client.py @@ -1,7 +1,7 @@ import datetime import logging from typing import Any -from urllib.parse import parse_qs, urlsplit +from urllib.parse import parse_qs, urlparse, urlsplit from requests import PreparedRequest @@ -10,12 +10,14 @@ from sentry.integrations.services.integration.model import RpcIntegration from sentry.integrations.types import IntegrationProviderSlug from sentry.integrations.utils.atlassian_connect import get_query_hash -from sentry.integrations.utils.jira import get_jira_key, parse_jira_issue_key +from sentry.integrations.utils.jira import parse_jira_issue_key from sentry.shared_integrations.exceptions import ApiError from sentry.utils import jwt +from sentry.utils.http import absolute_uri logger = logging.getLogger("sentry.integrations.jira") +JIRA_KEY = f"{urlparse(absolute_uri()).hostname}.jira" CUSTOMFIELD_PREFIX = "customfield_" STATUS_SEARCH_PAGE_SIZE = 200 @@ -77,7 +79,7 @@ def finalize_request(self, prepared_request: PreparedRequest): path = path.split("?")[0] now = datetime.datetime.now(datetime.UTC) jwt_payload = { - "iss": get_jira_key(), + "iss": JIRA_KEY, "iat": now, "exp": now + datetime.timedelta(seconds=5 * 60), "qsh": get_query_hash( @@ -217,7 +219,7 @@ def assign_issue(self, key, name_or_account_id): def set_issue_property(self, issue_key, badge_num): module_key = "sentry-issues-glance" - properties_key = f"com.atlassian.jira.issue:{get_jira_key()}:{module_key}:status" + properties_key = f"com.atlassian.jira.issue:{JIRA_KEY}:{module_key}:status" data = {"type": "badge", "value": {"label": badge_num}} return self.put(self.PROPERTIES_URL % (issue_key, properties_key), data=data) diff --git a/src/sentry/integrations/jira/endpoints/descriptor.py b/src/sentry/integrations/jira/endpoints/descriptor.py index 2008a331fcf2..1af2d7b43a3f 100644 --- a/src/sentry/integrations/jira/endpoints/descriptor.py +++ b/src/sentry/integrations/jira/endpoints/descriptor.py @@ -6,10 +6,11 @@ from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import Endpoint, control_silo_endpoint -from sentry.integrations.utils.jira import get_jira_key from sentry.utils.assets import get_frontend_app_asset_url from sentry.utils.http import absolute_uri +from .. import JIRA_KEY + scopes = ["read", "write", "act_as_user"] # For Jira, only approved apps can use the access_email_addresses scope # This scope allows Sentry to use the email endpoint (https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-rest-api-3-user-email-get) @@ -44,7 +45,7 @@ def get(self, request: Request) -> Response: { "name": "Sentry", "description": "Connect your Sentry organization to one or more of your Jira cloud instances. Get started streamlining your bug-squashing workflow by allowing your Sentry and Jira instances to work together.", - "key": get_jira_key(), + "key": JIRA_KEY, "baseUrl": absolute_uri(), "vendor": {"name": "Sentry", "url": "https://sentry.io"}, "authentication": {"type": "jwt"}, diff --git a/src/sentry/integrations/jira_server/client.py b/src/sentry/integrations/jira_server/client.py index 8e99c10b5138..0befa0a4b3a7 100644 --- a/src/sentry/integrations/jira_server/client.py +++ b/src/sentry/integrations/jira_server/client.py @@ -2,7 +2,7 @@ import logging from typing import Any -from urllib.parse import parse_qsl +from urllib.parse import parse_qsl, urlparse from django.urls import reverse from oauthlib.oauth1 import SIGNATURE_RSA @@ -15,7 +15,7 @@ from sentry.integrations.models.integration import Integration from sentry.integrations.services.integration.model import RpcIntegration from sentry.integrations.types import IntegrationProviderSlug -from sentry.integrations.utils.jira import get_jira_key, parse_jira_issue_key +from sentry.integrations.utils.jira import parse_jira_issue_key from sentry.integrations.utils.metrics import ( IntegrationPipelineViewEvent, IntegrationPipelineViewType, @@ -27,6 +27,7 @@ logger = logging.getLogger(__name__) +JIRA_KEY = f"{urlparse(absolute_uri()).hostname}.jira" CUSTOMFIELD_PREFIX = "customfield_" @@ -207,7 +208,7 @@ def assign_issue(self, key, name_or_account_id): def set_issue_property(self, issue_key, badge_num): module_key = "sentry-issues-glance" - properties_key = f"com.atlassian.jira.issue:{get_jira_key()}:{module_key}:status" + properties_key = f"com.atlassian.jira.issue:{JIRA_KEY}:{module_key}:status" data = {"type": "badge", "value": {"label": badge_num}} return self.put(self.PROPERTIES_URL % (issue_key, properties_key), data=data) diff --git a/src/sentry/integrations/utils/jira.py b/src/sentry/integrations/utils/jira.py index 2668531ece5f..e594ce5024a9 100644 --- a/src/sentry/integrations/utils/jira.py +++ b/src/sentry/integrations/utils/jira.py @@ -4,7 +4,6 @@ from urllib.parse import parse_qs, urlparse from sentry.integrations.utils.issue_url import get_url_origin -from sentry.utils.http import absolute_uri ISSUE_KEY_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*-\d+$") @@ -14,15 +13,6 @@ _ISSUE_PATH_PARENTS = frozenset({"browse", "issues"}) -def get_jira_key() -> str: - """ - The Atlassian Connect app key, shared by the cloud and server clients. Must match the `key` - served by the Jira descriptor endpoint, and can only be resolved once the options store is - available. - """ - return f"{urlparse(absolute_uri()).hostname}.jira" - - def _path_segments(path: str) -> list[str]: return [segment for segment in path.split("/") if segment] diff --git a/tests/sentry/integrations/jira/test_sentry_issue_details.py b/tests/sentry/integrations/jira/test_sentry_issue_details.py index 3b860dc8b58b..cfc61c1f5eab 100644 --- a/tests/sentry/integrations/jira/test_sentry_issue_details.py +++ b/tests/sentry/integrations/jira/test_sentry_issue_details.py @@ -4,11 +4,11 @@ import responses from jwt import ExpiredSignatureError +from sentry.integrations.jira import JIRA_KEY from sentry.integrations.jira.views import UNABLE_TO_VERIFY_INSTALLATION from sentry.integrations.models.external_issue import ExternalIssue from sentry.integrations.models.integration import Integration from sentry.integrations.utils.atlassian_connect import AtlassianConnectValidationError -from sentry.integrations.utils.jira import get_jira_key from sentry.models.group import Group from sentry.models.grouplink import GroupLink from sentry.models.project import Project @@ -80,9 +80,7 @@ def setUp(self) -> None: self.login_as(self.user) - self.properties_key = ( - f"com.atlassian.jira.issue:{get_jira_key()}:sentry-issues-glance:status" - ) + self.properties_key = f"com.atlassian.jira.issue:{JIRA_KEY}:sentry-issues-glance:status" self.properties_url = "https://getsentry.atlassian.net/rest/api/3/issue/%s/properties/%s" @patch( @@ -237,9 +235,7 @@ def setUp(self) -> None: self.login_as(self.user) - self.properties_key = ( - f"com.atlassian.jira.issue:{get_jira_key()}:sentry-issues-glance:status" - ) + self.properties_key = f"com.atlassian.jira.issue:{JIRA_KEY}:sentry-issues-glance:status" self.properties_url = "https://getsentry.atlassian.net/rest/api/3/issue/%s/properties/%s" @patch( From 6c595a59993e411c9a174fa2430e1f6946aee2d9 Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Tue, 22 Sep 2026 10:45:45 -0700 Subject: [PATCH 9/9] ref(np): Import integration packages inside renderer methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer modules are imported from `AppConfig.ready()`, which Django runs during `django.setup()` — before `initialize_app` calls `bind_cache_to_option_store()`. Importing `sentry.integrations.discord` or `sentry.integrations.slack` at module scope executes those packages' `__init__`, which reaches `sentry.notifications.notification_action` and from there the Jira action validator, whose module scope reads `system.url-prefix`. That read asserts while the options store cache is unbound. Deferring the imports into `render()` matches what the Discord and Slack providers and `IssueSlackRenderer` already do, and keeps the registrations eager at `ready()`. Co-authored-by: Cursor --- .../platform/discord/renderers/issue.py | 3 ++- .../platform/discord/renderers/metric_alert.py | 18 ++++++++++++------ .../platform/slack/renderers/metric_alert.py | 13 +++++++------ .../renderers/seer_agent_write_approval.py | 5 +++-- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/sentry/notifications/platform/discord/renderers/issue.py b/src/sentry/notifications/platform/discord/renderers/issue.py index 6b80b4b62974..e1dfe0a31aa7 100644 --- a/src/sentry/notifications/platform/discord/renderers/issue.py +++ b/src/sentry/notifications/platform/discord/renderers/issue.py @@ -1,7 +1,6 @@ from __future__ import annotations from sentry import eventstore -from sentry.integrations.discord.message_builder.issues import DiscordIssuesMessageBuilder from sentry.models.group import Group from sentry.notifications.platform.discord.provider import DiscordRenderable from sentry.notifications.platform.registry import renderer_registry @@ -26,6 +25,8 @@ def render[DataT: NotificationData]( if not isinstance(data, IssueNotificationData): raise ValueError(f"IssueDiscordRenderer does not support {data.__class__.__name__}") + from sentry.integrations.discord.message_builder.issues import DiscordIssuesMessageBuilder + # Retrieving Group and Event data is an anti-pattern, do not do this # in permanent renderers. try: diff --git a/src/sentry/notifications/platform/discord/renderers/metric_alert.py b/src/sentry/notifications/platform/discord/renderers/metric_alert.py index 8c0b07087b98..d2de41ad641a 100644 --- a/src/sentry/notifications/platform/discord/renderers/metric_alert.py +++ b/src/sentry/notifications/platform/discord/renderers/metric_alert.py @@ -1,12 +1,6 @@ from __future__ import annotations from sentry.incidents.models.incident import IncidentStatus -from sentry.integrations.discord.message_builder import INCIDENT_COLOR_MAPPING, LEVEL_TO_COLOR -from sentry.integrations.discord.message_builder.base.base import DiscordMessageBuilder -from sentry.integrations.discord.message_builder.base.embed.base import DiscordMessageEmbed -from sentry.integrations.discord.message_builder.base.embed.image import DiscordMessageEmbedImage -from sentry.integrations.discord.message_builder.metric_alerts import get_started_at -from sentry.integrations.metric_alerts import get_status_text from sentry.notifications.platform.discord.provider import DiscordRenderable from sentry.notifications.platform.registry import renderer_registry from sentry.notifications.platform.renderer import NotificationRenderer @@ -32,6 +26,18 @@ def render[DataT: NotificationData]( f"DiscordMetricAlertRenderer does not support '{data.__class__.__name__}'. Provide a MetricAlertNotificationData instead." ) + from sentry.integrations.discord.message_builder import ( + INCIDENT_COLOR_MAPPING, + LEVEL_TO_COLOR, + ) + from sentry.integrations.discord.message_builder.base.base import DiscordMessageBuilder + from sentry.integrations.discord.message_builder.base.embed.base import DiscordMessageEmbed + from sentry.integrations.discord.message_builder.base.embed.image import ( + DiscordMessageEmbedImage, + ) + from sentry.integrations.discord.message_builder.metric_alerts import get_started_at + from sentry.integrations.metric_alerts import get_status_text + status = get_status_text(IncidentStatus(data.new_status)) description = f"{data.text}{get_started_at(data.open_period_context.date_started)}" color = LEVEL_TO_COLOR.get(INCIDENT_COLOR_MAPPING.get(status, "")) diff --git a/src/sentry/notifications/platform/slack/renderers/metric_alert.py b/src/sentry/notifications/platform/slack/renderers/metric_alert.py index 1a60a55b53e7..7a03c22aef5c 100644 --- a/src/sentry/notifications/platform/slack/renderers/metric_alert.py +++ b/src/sentry/notifications/platform/slack/renderers/metric_alert.py @@ -1,12 +1,6 @@ from __future__ import annotations from sentry.incidents.models.incident import IncidentStatus -from sentry.integrations.messaging.types import LEVEL_TO_COLOR -from sentry.integrations.metric_alerts import get_status_text -from sentry.integrations.slack.message_builder.base.block import BlockSlackMessageBuilder -from sentry.integrations.slack.message_builder.incidents import get_started_at -from sentry.integrations.slack.message_builder.types import INCIDENT_COLOR_MAPPING -from sentry.integrations.slack.utils.escape import escape_slack_text from sentry.notifications.platform.registry import renderer_registry from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.slack.provider import SlackRenderable @@ -30,6 +24,13 @@ def render[DataT: NotificationData]( if not isinstance(data, MetricAlertNotificationData): raise ValueError(f"SlackMetricAlertRenderer does not support {data.__class__.__name__}") + from sentry.integrations.messaging.types import LEVEL_TO_COLOR + from sentry.integrations.metric_alerts import get_status_text + from sentry.integrations.slack.message_builder.base.block import BlockSlackMessageBuilder + from sentry.integrations.slack.message_builder.incidents import get_started_at + from sentry.integrations.slack.message_builder.types import INCIDENT_COLOR_MAPPING + from sentry.integrations.slack.utils.escape import escape_slack_text + status = get_status_text(IncidentStatus(data.new_status)) incident_text = f"{data.text}\n{get_started_at(data.open_period_context.date_started)}" diff --git a/src/sentry/notifications/platform/slack/renderers/seer_agent_write_approval.py b/src/sentry/notifications/platform/slack/renderers/seer_agent_write_approval.py index 7a62987f0252..51dfddf0a172 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer_agent_write_approval.py +++ b/src/sentry/notifications/platform/slack/renderers/seer_agent_write_approval.py @@ -1,8 +1,6 @@ from django.conf import settings from slack_sdk.models.blocks import ActionsBlock, ButtonElement, MarkdownBlock -from sentry.integrations.slack.message_builder.routing import encode_action_id -from sentry.integrations.slack.message_builder.types import SlackAction from sentry.notifications.platform.registry import renderer_registry from sentry.notifications.platform.renderer import NotificationRenderer from sentry.notifications.platform.slack.provider import SlackRenderable @@ -28,6 +26,9 @@ def render[DataT: NotificationData]( f"SeerAgentWriteApprovalSlackRenderer does not support {data.__class__.__name__}" ) + from sentry.integrations.slack.message_builder.routing import encode_action_id + from sentry.integrations.slack.message_builder.types import SlackAction + scope_descriptions = { scope: description for scope_set in settings.SENTRY_SCOPE_SETS