From 0da356505463df27d7728135ebc4cba10ec37c49 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Thu, 18 Jun 2026 15:51:23 -0700 Subject: [PATCH 1/3] ref(webhooks): remove legacy sentry_webhooks plugin The old `sentry_webhooks` plugin has been fully replaced by the `legacy_webhook` SentryApp service. This removes the dead plugin code and adds a lightweight stub in `get_notification_plugins_for_org()` so ACI discovery still finds "webhooks" via ProjectOption. - Delete `src/sentry/plugins/sentry_webhooks/` and its tests - Remove from INSTALLED_APPS, HIDDEN_PLUGINS, and mypy exclusions - Remove skip_webhooks feature flag logic from notify_event.py - Update test fixtures to use direct ProjectOption calls --- .github/codeowners-coverage-baseline.txt | 2 - pyproject.toml | 1 - src/sentry/conf/server.py | 1 - src/sentry/plugins/__init__.py | 1 - .../plugins/sentry_webhooks/__init__.py | 0 src/sentry/plugins/sentry_webhooks/apps.py | 12 -- src/sentry/plugins/sentry_webhooks/client.py | 22 --- src/sentry/plugins/sentry_webhooks/plugin.py | 152 ------------------ src/sentry/rules/actions/notify_event.py | 7 - .../workflow_engine/processors/action.py | 17 ++ .../plugins/sentry_webhooks/__init__.py | 0 .../plugins/sentry_webhooks/test_plugin.py | 102 ------------ .../sentry/rules/actions/test_notify_event.py | 12 -- .../actions/test_notify_event_service.py | 9 +- ...est_organization_available_action_index.py | 7 +- .../validators/actions/test_webhook.py | 8 +- 16 files changed, 28 insertions(+), 325 deletions(-) delete mode 100644 src/sentry/plugins/sentry_webhooks/__init__.py delete mode 100644 src/sentry/plugins/sentry_webhooks/apps.py delete mode 100644 src/sentry/plugins/sentry_webhooks/client.py delete mode 100644 src/sentry/plugins/sentry_webhooks/plugin.py delete mode 100644 tests/sentry/plugins/sentry_webhooks/__init__.py delete mode 100644 tests/sentry/plugins/sentry_webhooks/test_plugin.py diff --git a/.github/codeowners-coverage-baseline.txt b/.github/codeowners-coverage-baseline.txt index 5d0a0cc80307..9b56cb93f287 100644 --- a/.github/codeowners-coverage-baseline.txt +++ b/.github/codeowners-coverage-baseline.txt @@ -2196,8 +2196,6 @@ tests/sentry/plugins/interfaces/__init__.py tests/sentry/plugins/interfaces/test_releasehook.py tests/sentry/plugins/sentry_useragents/__init__.py tests/sentry/plugins/sentry_useragents/test_models.py -tests/sentry/plugins/sentry_webhooks/__init__.py -tests/sentry/plugins/sentry_webhooks/test_plugin.py tests/sentry/plugins/test_config.py tests/sentry/plugins/test_helpers.py tests/sentry/plugins/test_integration_repository.py diff --git a/pyproject.toml b/pyproject.toml index 3caf0b14470a..3847c112030b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1225,7 +1225,6 @@ module = [ "sentry.plugins.sentry_interface_types.*", "sentry.plugins.sentry_urls.*", "sentry.plugins.sentry_useragents.*", - "sentry.plugins.sentry_webhooks.*", "sentry.plugins.utils", "sentry.processing.*", "sentry.processing_errors.*", diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index aa0a75d81daf..14d7eaf20f63 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -488,7 +488,6 @@ def env( "sentry.plugins.sentry_interface_types.apps.Config", "sentry.plugins.sentry_urls.apps.Config", "sentry.plugins.sentry_useragents.apps.Config", - "sentry.plugins.sentry_webhooks.apps.Config", "social_auth", "sudo", "sentry.eventstream", diff --git a/src/sentry/plugins/__init__.py b/src/sentry/plugins/__init__.py index 0bf511e3120d..21f192bc3eae 100644 --- a/src/sentry/plugins/__init__.py +++ b/src/sentry/plugins/__init__.py @@ -6,7 +6,6 @@ "jira", "pagerduty", "opsgenie", - "webhooks", "amazon-sqs", "asana", "trello", diff --git a/src/sentry/plugins/sentry_webhooks/__init__.py b/src/sentry/plugins/sentry_webhooks/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/src/sentry/plugins/sentry_webhooks/apps.py b/src/sentry/plugins/sentry_webhooks/apps.py deleted file mode 100644 index 3151298b8015..000000000000 --- a/src/sentry/plugins/sentry_webhooks/apps.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.apps import AppConfig - - -class Config(AppConfig): - name = "sentry.plugins.sentry_webhooks" - - def ready(self) -> None: - from sentry.plugins.base import register - - from .plugin import WebHooksPlugin - - register(WebHooksPlugin) diff --git a/src/sentry/plugins/sentry_webhooks/client.py b/src/sentry/plugins/sentry_webhooks/client.py deleted file mode 100644 index 51b97bd8b770..000000000000 --- a/src/sentry/plugins/sentry_webhooks/client.py +++ /dev/null @@ -1,22 +0,0 @@ -from sentry_plugins.client import ApiClient - - -class WebhookApiClient(ApiClient): - plugin_name = "webhook" - allow_redirects = False - metrics_prefix = "integrations.webhook" - - def __init__(self, data): - self.data = data - super().__init__(verify_ssl=False) - - def request(self, url): - return self._request( - path=url, - method="post", - data=self.data, - json=True, - timeout=5, - allow_text=True, - ignore_webhook_errors=True, - ) diff --git a/src/sentry/plugins/sentry_webhooks/plugin.py b/src/sentry/plugins/sentry_webhooks/plugin.py deleted file mode 100644 index de6b886da454..000000000000 --- a/src/sentry/plugins/sentry_webhooks/plugin.py +++ /dev/null @@ -1,152 +0,0 @@ -from __future__ import annotations - -import logging - -from django import forms -from django.conf import settings -from django.utils.translation import gettext_lazy as _ -from requests.exceptions import ConnectionError, ReadTimeout - -import sentry -from sentry.exceptions import PluginError, RestrictedIPAddress -from sentry.integrations.base import FeatureDescription, IntegrationFeatures -from sentry.net.socket import is_valid_url -from sentry.plugins.bases import notify -from sentry.sentry_apps.services.legacy_webhook.tasks import LegacyWebhookOutcome -from sentry.shared_integrations.exceptions import ApiError -from sentry.utils import metrics - -from .client import WebhookApiClient - -DESCRIPTION = """ -Trigger outgoing HTTP POST requests from Sentry. - -Note: To configure webhooks over multiple projects, we recommend setting up an -Internal Integration. -""" - - -def split_urls(value: str) -> list[str]: - if not value: - return [] - return list(filter(bool, (url.strip() for url in value.splitlines()))) - - -def validate_urls(value: str, **kwargs: object) -> str: - urls = split_urls(value) - if any((not u.startswith(("http://", "https://")) or not is_valid_url(u)) for u in urls): - raise PluginError("Not a valid URL.") - return "\n".join(urls) - - -class WebHooksOptionsForm(notify.NotificationConfigurationForm): - urls = forms.CharField( - label=_("Callback URLs"), - widget=forms.Textarea( - attrs={"class": "span6", "placeholder": "https://sentry.io/callback/url"} - ), - help_text=_("Enter callback URLs to POST new events to (one per line)."), - ) - - -class WebHooksPlugin(notify.NotificationPlugin): - author = "Sentry Team" - author_url = "https://github.com/getsentry/sentry" - version = sentry.VERSION - description = DESCRIPTION - resource_links = [ - ("Report Issue", "https://github.com/getsentry/sentry/issues"), - ( - "View Source", - "https://github.com/getsentry/sentry/tree/master/src/sentry/plugins/sentry_webhooks", - ), - ( - "Internal Integrations", - "https://docs.sentry.io/workflow/integrations/integration-platform/#internal-integrations", - ), - ] - - slug = "webhooks" - title = "WebHooks" - conf_title = title - conf_key = "webhooks" - # TODO(dcramer): remove when this is migrated to React - project_conf_form = WebHooksOptionsForm - timeout = getattr(settings, "SENTRY_WEBHOOK_TIMEOUT", 3) - logger = logging.getLogger("sentry.plugins.webhooks") - user_agent = "sentry-webhooks/%s" % version - required_field = "urls" - feature_descriptions = [ - FeatureDescription( - """ - Configure rule based outgoing HTTP POST requests from Sentry. - """, - IntegrationFeatures.ALERT_RULE, - ) - ] - - def is_configured(self, project) -> bool: - return bool(self.get_option("urls", project)) - - def get_config(self, project, user=None, initial=None, add_additional_fields: bool = False): - return [ - { - "name": "urls", - "label": "Callback URLs", - "type": "textarea", - "help": "Enter callback URLs to POST new events to (one per line).", - "placeholder": "https://sentry.io/callback/url", - "validators": [validate_urls], - "required": False, - } - ] - - def get_group_data(self, group, event, triggering_rules): - data = { - "id": str(group.id), - "project": group.project.slug, - "project_name": group.project.name, - "project_slug": group.project.slug, - "logger": event.get_tag("logger"), - "level": event.get_tag("level"), - "culprit": group.culprit, - "message": event.message, - "url": group.get_absolute_url(params={"referrer": "webhooks_plugin"}), - # TODO(ecosystem): We need to eventually change the key on this - "triggering_rules": triggering_rules, - } - data["event"] = dict(event.data or {}) - data["event"]["tags"] = event.tags - data["event"]["event_id"] = event.event_id - data["event"]["id"] = event.event_id - return data - - def get_webhook_urls(self, project): - return split_urls(self.get_option("urls", project)) - - def get_client(self, payload): - return WebhookApiClient(payload) - - def notify_users(self, group, event, triggering_rules) -> None: - payload = self.get_group_data(group, event, triggering_rules) - client = self.get_client(payload) - for url in self.get_webhook_urls(group.project): - try: - client.request(url) - metrics.incr( - "legacy_webhook.plugin.send", - tags={"outcome": LegacyWebhookOutcome.SENT}, - sample_rate=1.0, - ) - except (RestrictedIPAddress, ApiError): - metrics.incr( - "legacy_webhook.plugin.send", - tags={"outcome": LegacyWebhookOutcome.ERROR}, - sample_rate=1.0, - ) - except (ConnectionError, ReadTimeout): - metrics.incr( - "legacy_webhook.plugin.send", - tags={"outcome": LegacyWebhookOutcome.ERROR}, - sample_rate=1.0, - ) diff --git a/src/sentry/rules/actions/notify_event.py b/src/sentry/rules/actions/notify_event.py index d6698b0d9c9e..0a73015ea997 100644 --- a/src/sentry/rules/actions/notify_event.py +++ b/src/sentry/rules/actions/notify_event.py @@ -1,6 +1,5 @@ from collections.abc import Generator, Sequence -from sentry import features from sentry.plugins.base import plugins from sentry.rules.actions.base import EventAction from sentry.rules.actions.services import LegacyPluginService @@ -20,16 +19,10 @@ class NotifyEventAction(EventAction): def get_plugins(self) -> Sequence[LegacyPluginService]: from sentry.plugins.bases.notify import NotificationPlugin - skip_webhooks = features.has( - "organizations:legacy-webhook-disable-old-path", self.project.organization - ) - results = [] for plugin in plugins.for_project(self.project, version=1): if not isinstance(plugin, NotificationPlugin): continue - if skip_webhooks and plugin.slug == "webhooks": - continue results.append(LegacyPluginService(plugin)) return results diff --git a/src/sentry/workflow_engine/processors/action.py b/src/sentry/workflow_engine/processors/action.py index b11bbc0ca197..508167c3bc06 100644 --- a/src/sentry/workflow_engine/processors/action.py +++ b/src/sentry/workflow_engine/processors/action.py @@ -14,6 +14,7 @@ from sentry.integrations.services.integration import RpcIntegration, integration_service from sentry.integrations.types import IntegrationProviderSlug from sentry.models.group import Group +from sentry.models.options.project_option import ProjectOption from sentry.models.organization import Organization from sentry.models.project import Project from sentry.plugins.base import plugins @@ -344,6 +345,13 @@ def get_available_action_integrations_for_org(organization: Organization) -> lis ) +class _LegacyWebhookStub: + slug = "webhooks" + + def get_title(self) -> str: + return "WebHooks" + + def get_notification_plugins_for_org(organization: Organization) -> list[PluginService]: """ Get all plugins for an organization. @@ -362,6 +370,15 @@ def get_notification_plugins_for_org(organization: Organization) -> list[PluginS plugin_map[plugin.slug] = PluginService(plugin) + if "webhooks" not in plugin_map: + has_webhooks = ProjectOption.objects.filter( + project__organization_id=organization.id, + key="webhooks:enabled", + value=True, + ).exists() + if has_webhooks: + plugin_map["webhooks"] = PluginService(_LegacyWebhookStub()) + return list(plugin_map.values()) diff --git a/tests/sentry/plugins/sentry_webhooks/__init__.py b/tests/sentry/plugins/sentry_webhooks/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/tests/sentry/plugins/sentry_webhooks/test_plugin.py b/tests/sentry/plugins/sentry_webhooks/test_plugin.py deleted file mode 100644 index 3bb04489cc8e..000000000000 --- a/tests/sentry/plugins/sentry_webhooks/test_plugin.py +++ /dev/null @@ -1,102 +0,0 @@ -from functools import cached_property - -import pytest -import responses - -from sentry.exceptions import PluginError -from sentry.plugins.base import Notification -from sentry.plugins.sentry_webhooks.plugin import WebHooksOptionsForm, WebHooksPlugin, validate_urls -from sentry.testutils.cases import TestCase -from sentry.testutils.skips import requires_snuba -from sentry.utils import json - -pytestmark = [requires_snuba] - - -class WebHooksPluginTest(TestCase): - @cached_property - def plugin(self) -> WebHooksPlugin: - return WebHooksPlugin() - - def setUp(self) -> None: - self.event = self.store_event( - data={"message": "Hello world", "level": "warning"}, project_id=self.project.id - ) - rule = self.create_project_rule(name="my rule") - self.notification = Notification(event=self.event, rule=rule) - self.project.update_option("webhooks:urls", "http://example.com") - - @responses.activate - def test_simple_notification(self) -> None: - responses.add(responses.POST, "http://example.com") - - self.plugin.notify(self.notification) - - assert len(responses.calls) == 1 - - payload = json.loads(responses.calls[0].request.body) - assert payload["level"] == "warning" - assert payload["message"] == "Hello world" - assert payload["event"]["id"] == self.event.event_id - assert payload["event"]["event_id"] == self.event.event_id - assert payload["triggering_rules"] == ["my rule"] - - @responses.activate - def test_unsupported_text_response(self) -> None: - """Test that a response of just text doesn't raise an error""" - responses.add( - responses.POST, - "http://example.com", - body='"some text"', - content_type="application/json", - ) - - self.plugin.notify(self.notification) # does not raise! - - assert len(responses.calls) == 1 - assert responses.calls[0].response.status_code == 200 - - @responses.activate - def test_unsupported_null_response(self) -> None: - """Test that a response of null doesn't raise an error""" - responses.add( - responses.POST, "http://example.com", body="null", content_type="application/json" - ) - - self.plugin.notify(self.notification) # does not raise! - - assert len(responses.calls) == 1 - assert responses.calls[0].response.status_code == 200 - - @responses.activate - def test_unsupported_int_response(self) -> None: - """Test that a response of an integer doesn't raise an error""" - responses.add( - responses.POST, "http://example.com", body="1", content_type="application/json" - ) - - self.plugin.notify(self.notification) # does not raise! - - assert len(responses.calls) == 1 - assert responses.calls[0].response.status_code == 200 - - def test_webhook_validation(self) -> None: - # Test that you can't sneak a bad domain into the list of webhooks - # without it being validated by delimiting with \r instead of \n - bad_urls = "http://example.com\rftp://baddomain.com" - form = WebHooksOptionsForm(data={"urls": bad_urls}) - form.is_valid() - - with pytest.raises(PluginError): - validate_urls(form.cleaned_data["urls"]) - - @responses.activate - def test_moved_permanently(self) -> None: - """Test that we do not raise an error for 301s""" - - responses.add(responses.POST, "http://example.com", body=" None: assert len(results) == 1 assert plugin.should_notify.call_count == 1 assert results[0].callback is plugin.rule_notify - - def test_get_plugins_includes_webhooks_by_default(self) -> None: - ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://example.com/hook") - webhook_plugin = plugins.get("webhooks") - webhook_plugin.set_option("enabled", True, self.project) - - rule = self.get_rule() - result_slugs = [p.service.slug for p in rule.get_plugins()] - - assert "webhooks" in result_slugs diff --git a/tests/sentry/rules/actions/test_notify_event_service.py b/tests/sentry/rules/actions/test_notify_event_service.py index 031f7a173fda..1f896ae8f62f 100644 --- a/tests/sentry/rules/actions/test_notify_event_service.py +++ b/tests/sentry/rules/actions/test_notify_event_service.py @@ -15,8 +15,8 @@ MetricIssueContext, NotificationContext, ) +from sentry.models.options.project_option import ProjectOption from sentry.models.rule import Rule -from sentry.plugins.sentry_webhooks.plugin import WebHooksPlugin from sentry.rules.actions.notify_event_service import ( NotifyEventServiceAction, send_incident_alert_notification, @@ -63,11 +63,10 @@ def test_applies_correctly_for_plugins(self) -> None: class NotifyEventServiceWebhookActionTest(NotifyEventServiceActionTest): def setUp(self) -> None: self.event = self.get_event() - self.webhook = WebHooksPlugin() - self.webhook.set_option( - project=self.event.project, key="urls", value="http://my-fake-webhook.io" + ProjectOption.objects.set_value( + self.event.project, "webhooks:urls", "http://my-fake-webhook.io" ) - self.webhook.set_option(project=self.event.project, key="enabled", value=True) + ProjectOption.objects.set_value(self.event.project, "webhooks:enabled", True) self.rule_webhook_data = { "conditions": [ diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_available_action_index.py b/tests/sentry/workflow_engine/endpoints/test_organization_available_action_index.py index 71c2c579b23c..d2169735faf6 100644 --- a/tests/sentry/workflow_engine/endpoints/test_organization_available_action_index.py +++ b/tests/sentry/workflow_engine/endpoints/test_organization_available_action_index.py @@ -5,11 +5,11 @@ from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.pagerduty.utils import add_service from sentry.integrations.types import IntegrationProviderSlug +from sentry.models.options.project_option import ProjectOption from sentry.notifications.notification_action.action_handler_registry.base import ( IntegrationActionHandler, ) from sentry.plugins.base.manager import PluginManager -from sentry.plugins.sentry_webhooks.plugin import WebHooksPlugin from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase from sentry.testutils.helpers import with_feature @@ -247,9 +247,8 @@ class WebhookActionHandler(ActionHandler): config_schema = {} data_schema = {} - self.plugin_registry.register(WebHooksPlugin) - self.webhooks_plugin = self.plugin_registry.get(WebHooksPlugin.slug) - self.webhooks_plugin.enable(self.project) + ProjectOption.objects.set_value(self.project, "webhooks:enabled", True) + ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://example.com") self.plugin_registry.register(SlackPlugin) self.slack_plugin = self.plugin_registry.get(SlackPlugin.slug) diff --git a/tests/sentry/workflow_engine/endpoints/validators/actions/test_webhook.py b/tests/sentry/workflow_engine/endpoints/validators/actions/test_webhook.py index 333c2f517e8e..14730325e821 100644 --- a/tests/sentry/workflow_engine/endpoints/validators/actions/test_webhook.py +++ b/tests/sentry/workflow_engine/endpoints/validators/actions/test_webhook.py @@ -1,7 +1,7 @@ from rest_framework.exceptions import ErrorDetail +from sentry.models.options.project_option import ProjectOption from sentry.plugins.base import plugins -from sentry.plugins.sentry_webhooks.plugin import WebHooksPlugin from sentry.testutils.cases import TestCase from sentry.workflow_engine.endpoints.validators.base import BaseActionValidator from sentry.workflow_engine.models import Action @@ -11,8 +11,8 @@ class TestWebhookActionValidator(TestCase): def setUp(self) -> None: super().setUp() - self.webhooks_plugin = plugins.get(WebHooksPlugin.slug) - self.webhooks_plugin.enable(self.project) + ProjectOption.objects.set_value(self.project, "webhooks:enabled", True) + ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://example.com") # non notification plugin self.trello_plugin = plugins.get(TrelloPlugin.slug) @@ -74,7 +74,7 @@ def test_validate__invalid_sentry_app(self) -> None: def test_validate__plugin(self) -> None: validator = BaseActionValidator( - data={**self.valid_data, "config": {"targetIdentifier": self.webhooks_plugin.slug}}, + data={**self.valid_data, "config": {"targetIdentifier": "webhooks"}}, context={"organization": self.organization}, ) From 4671a152e3fbb952a26e3359c0c6813465e5e210 Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Thu, 18 Jun 2026 15:58:38 -0700 Subject: [PATCH 2/3] ref(webhooks): decouple legacy webhook discovery from plugin system Move webhook discovery out of get_notification_plugins_for_org() into a standalone get_legacy_webhook_service() function that checks ProjectOption directly. Both ACI call sites now add the webhook service independently of the plugin loop, making it easier to remove plugin discovery entirely in the future. --- .../notification_action/action_validation.py | 8 +++- .../organization_available_action_index.py | 6 ++- .../workflow_engine/processors/action.py | 38 +++++++++++-------- 3 files changed, 34 insertions(+), 18 deletions(-) diff --git a/src/sentry/notifications/notification_action/action_validation.py b/src/sentry/notifications/notification_action/action_validation.py index 894fd040b54b..0d1b13dcb9d8 100644 --- a/src/sentry/notifications/notification_action/action_validation.py +++ b/src/sentry/notifications/notification_action/action_validation.py @@ -19,7 +19,10 @@ from sentry.sentry_apps.utils.errors import SentryAppBaseError from sentry.utils import json from sentry.workflow_engine.models.action import Action -from sentry.workflow_engine.processors.action import get_notification_plugins_for_org +from sentry.workflow_engine.processors.action import ( + get_legacy_webhook_service, + get_notification_plugins_for_org, +) from .types import BaseActionValidatorHandler @@ -257,6 +260,9 @@ class WebhookActionValidatorHandler(BaseActionValidatorHandler): def _get_services(self) -> list[Any]: plugins = get_notification_plugins_for_org(self.organization) + legacy_webhook = get_legacy_webhook_service(self.organization) + if legacy_webhook and not any(p.slug == "webhooks" for p in plugins): + plugins.append(legacy_webhook) sentry_apps = app_service.find_alertable_services(organization_id=self.organization.id) return [ *plugins, diff --git a/src/sentry/workflow_engine/endpoints/organization_available_action_index.py b/src/sentry/workflow_engine/endpoints/organization_available_action_index.py index ecdaf0dc3833..75cded0667ae 100644 --- a/src/sentry/workflow_engine/endpoints/organization_available_action_index.py +++ b/src/sentry/workflow_engine/endpoints/organization_available_action_index.py @@ -33,6 +33,7 @@ from sentry.workflow_engine.processors.action import ( get_available_action_integrations_for_org, get_integration_services, + get_legacy_webhook_service, get_notification_plugins_for_org, ) from sentry.workflow_engine.registry import action_handler_registry @@ -142,9 +143,12 @@ def get( ) # add webhook action - # service options include plugins and sentry apps without components + # service options include legacy webhooks, plugins, and sentry apps without components elif action_type == Action.Type.WEBHOOK: plugins = get_notification_plugins_for_org(organization) + legacy_webhook = get_legacy_webhook_service(organization) + if legacy_webhook and not any(p.slug == "webhooks" for p in plugins): + plugins.append(legacy_webhook) sentry_apps: list[PluginService] = [ SentryAppService(context.installation.sentry_app) for context in alertable_apps_without_components diff --git a/src/sentry/workflow_engine/processors/action.py b/src/sentry/workflow_engine/processors/action.py index 508167c3bc06..887412f8641c 100644 --- a/src/sentry/workflow_engine/processors/action.py +++ b/src/sentry/workflow_engine/processors/action.py @@ -345,13 +345,6 @@ def get_available_action_integrations_for_org(organization: Organization) -> lis ) -class _LegacyWebhookStub: - slug = "webhooks" - - def get_title(self) -> str: - return "WebHooks" - - def get_notification_plugins_for_org(organization: Organization) -> list[PluginService]: """ Get all plugins for an organization. @@ -370,18 +363,31 @@ def get_notification_plugins_for_org(organization: Organization) -> list[PluginS plugin_map[plugin.slug] = PluginService(plugin) - if "webhooks" not in plugin_map: - has_webhooks = ProjectOption.objects.filter( - project__organization_id=organization.id, - key="webhooks:enabled", - value=True, - ).exists() - if has_webhooks: - plugin_map["webhooks"] = PluginService(_LegacyWebhookStub()) - return list(plugin_map.values()) +class _LegacyWebhookStub: + slug = "webhooks" + + def get_title(self) -> str: + return "WebHooks" + + +def get_legacy_webhook_service(organization: Organization) -> PluginService | None: + """ + Check if any project in the org has legacy webhooks enabled and return a + service for ACI discovery. This is independent of the plugin system. + """ + has_webhooks = ProjectOption.objects.filter( + project__organization_id=organization.id, + key="webhooks:enabled", + value=True, + ).exists() + if has_webhooks: + return PluginService(_LegacyWebhookStub()) + return None + + def get_integration_services(organization_id: int) -> dict[int, list[tuple[int, str]]]: """ Get all Pagerduty services and Opsgenie teams for an organization's integrations. From f14fe74d1692c5a71d642ff5b3741aaad1a5603f Mon Sep 17 00:00:00 2001 From: Christinarlong Date: Thu, 18 Jun 2026 16:03:04 -0700 Subject: [PATCH 3/3] fix(webhooks): filter soft-deleted projects from webhook discovery Add project__status=ObjectStatus.ACTIVE to the ProjectOption query so soft-deleted projects with stale webhooks:enabled options don't cause the webhook service to appear in ACI discovery. --- src/sentry/workflow_engine/processors/action.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sentry/workflow_engine/processors/action.py b/src/sentry/workflow_engine/processors/action.py index 887412f8641c..137b69ae215c 100644 --- a/src/sentry/workflow_engine/processors/action.py +++ b/src/sentry/workflow_engine/processors/action.py @@ -380,6 +380,7 @@ def get_legacy_webhook_service(organization: Organization) -> PluginService | No """ has_webhooks = ProjectOption.objects.filter( project__organization_id=organization.id, + project__status=ObjectStatus.ACTIVE, key="webhooks:enabled", value=True, ).exists()