diff --git a/src/sentry/models/deploy.py b/src/sentry/models/deploy.py index c867c33859fa88..3b61425cc158eb 100644 --- a/src/sentry/models/deploy.py +++ b/src/sentry/models/deploy.py @@ -1,22 +1,16 @@ -from sentry.backup.scopes import RelocationScope -from sentry.db.models import region_silo_model - -""" -sentry.models.deploy -~~~~~~~~~~~~~~~~~~~~ -""" - - from django.db import models from django.utils import timezone +from sentry.backup.scopes import RelocationScope from sentry.db.models import ( BoundedBigIntegerField, BoundedPositiveIntegerField, FlexibleForeignKey, Model, + region_silo_model, ) from sentry.locks import locks +from sentry.models.environment import Environment from sentry.types.activity import ActivityType from sentry.utils.retries import TimedRetryPolicy @@ -49,7 +43,6 @@ def notify_if_ready(cls, deploy_id, fetch_complete=False): if they haven't been sent """ from sentry.models.activity import Activity - from sentry.models.environment import Environment from sentry.models.releasecommit import ReleaseCommit from sentry.models.releaseheadcommit import ReleaseHeadCommit diff --git a/src/sentry/models/releaseprojectenvironment.py b/src/sentry/models/releaseprojectenvironment.py index 3b7ba4b7d85ab9..561339b6c7ceed 100644 --- a/src/sentry/models/releaseprojectenvironment.py +++ b/src/sentry/models/releaseprojectenvironment.py @@ -1,5 +1,8 @@ +from __future__ import annotations + from datetime import timedelta from enum import Enum +from typing import TYPE_CHECKING, ClassVar from django.db import models from django.utils import timezone @@ -12,9 +15,17 @@ region_silo_model, sane_repr, ) +from sentry.db.models.manager import BaseManager +from sentry.incidents.utils.types import AlertRuleActivationConditionType from sentry.utils import metrics from sentry.utils.cache import cache +if TYPE_CHECKING: + from sentry.models.environment import Environment + from sentry.models.project import Project + from sentry.models.release import Release + from sentry.snuba.models import QuerySubscription + class ReleaseStages(str, Enum): ADOPTED = "adopted" @@ -22,6 +33,42 @@ class ReleaseStages(str, Enum): REPLACED = "replaced" +class ReleaseProjectEnvironmentManager(BaseManager["ReleaseProjectEnvironment"]): + @staticmethod + def subscribe_project_to_alert_rule( + project: Project, release: Release, environment: Environment, trigger: str + ) -> list[QuerySubscription]: + """ + TODO: potentially enable custom query_extra to be passed on ReleaseProject creation (on release/deploy) + + NOTE: import AlertRule model here to avoid circular dependency + """ + from sentry.incidents.models.alert_rule import AlertRule + + query_extra = f"release:{release.version} and environment:{environment.name}" + # TODO: parse activator on the client to derive release version / environment name + activator = f"release:{release.version} and environment:{environment.name}" + return AlertRule.objects.conditionally_subscribe_project_to_alert_rules( + project=project, + activation_condition=AlertRuleActivationConditionType.DEPLOY_CREATION, + query_extra=query_extra, + origin=trigger, + activator=activator, + ) + + def post_save(self, instance, created, **kwargs): + if created: + release = instance.release + project = instance.project + environment = instance.environment + self.subscribe_project_to_alert_rule( + project=project, + release=release, + environment=environment, + trigger="releaseprojectenvironment.post_save", + ) + + @region_silo_model class ReleaseProjectEnvironment(Model): __relocation_scope__ = RelocationScope.Excluded @@ -37,6 +84,8 @@ class ReleaseProjectEnvironment(Model): adopted = models.DateTimeField(null=True, blank=True) unadopted = models.DateTimeField(null=True, blank=True) + objects: ClassVar[ReleaseProjectEnvironmentManager] = ReleaseProjectEnvironmentManager() + class Meta: app_label = "sentry" db_table = "sentry_releaseprojectenvironment" diff --git a/static/app/views/alerts/rules/metric/ruleConditionsForm.tsx b/static/app/views/alerts/rules/metric/ruleConditionsForm.tsx index c954883ff180b4..27eb6b1c3e401f 100644 --- a/static/app/views/alerts/rules/metric/ruleConditionsForm.tsx +++ b/static/app/views/alerts/rules/metric/ruleConditionsForm.tsx @@ -462,6 +462,10 @@ class RuleConditionsForm extends PureComponent { value: ActivationConditionType.RELEASE_CREATION, label: t('New Release'), }, + { + value: ActivationConditionType.DEPLOY_CREATION, + label: t('New Deploy'), + }, ]} required value={activationCondition} diff --git a/tests/sentry/models/test_releaseprojectenvironment.py b/tests/sentry/models/test_releaseprojectenvironment.py index 89768e10d45f4a..4131f5b201f0cb 100644 --- a/tests/sentry/models/test_releaseprojectenvironment.py +++ b/tests/sentry/models/test_releaseprojectenvironment.py @@ -1,10 +1,19 @@ from datetime import timedelta +from unittest.mock import call as mock_call +from unittest.mock import patch from django.utils import timezone +from sentry.incidents.models.alert_rule import AlertRule, AlertRuleMonitorType +from sentry.incidents.utils.types import AlertRuleActivationConditionType from sentry.models.environment import Environment from sentry.models.release import Release -from sentry.models.releaseprojectenvironment import ReleaseProjectEnvironment +from sentry.models.releaseprojectenvironment import ( + ReleaseProjectEnvironment, + ReleaseProjectEnvironmentManager, +) +from sentry.signals import receivers_raise_on_send +from sentry.snuba.models import QuerySubscription from sentry.testutils.cases import TestCase @@ -83,3 +92,68 @@ def test_no_update_too_close(self): ) assert release_project_env.first_seen == self.datetime_now assert release_project_env.last_seen == self.datetime_now + + @receivers_raise_on_send() + @patch.object(ReleaseProjectEnvironmentManager, "subscribe_project_to_alert_rule") + def test_post_save_subscribes_project_to_alert_rule_if_created( + self, mock_subscribe_project_to_alert_rule + ): + ReleaseProjectEnvironment.get_or_create( + project=self.project, + release=self.release, + environment=self.environment, + datetime=self.datetime_now, + ) + + assert mock_subscribe_project_to_alert_rule.call_count == 1 + + @patch( + "sentry.incidents.models.alert_rule.AlertRule.objects.conditionally_subscribe_project_to_alert_rules" + ) + def test_subscribe_project_to_alert_rule_constructs_query(self, mock_conditionally_subscribe): + ReleaseProjectEnvironmentManager.subscribe_project_to_alert_rule( + project=self.project, release=self.release, environment=self.environment, trigger="test" + ) + + assert mock_conditionally_subscribe.call_count == 1 + assert mock_conditionally_subscribe.mock_calls == [ + mock_call( + project=self.project, + activation_condition=AlertRuleActivationConditionType.DEPLOY_CREATION, + query_extra=f"release:{self.release.version} and environment:{self.environment.name}", + origin="test", + activator=f"release:{self.release.version} and environment:{self.environment.name}", + ) + ] + + def test_unmocked_subscribe_project_to_alert_rule_constructs_query(self): + # Let the logic flow through to snuba and see whether we properly construct the snuba query + # project = self.create_project(name="foo") + # release = Release.objects.create(organization_id=project.organization_id, version="42") + self.create_alert_rule( + projects=[self.project], + monitor_type=AlertRuleMonitorType.ACTIVATED, + activation_condition=AlertRuleActivationConditionType.DEPLOY_CREATION, + ) + + subscribe_project = AlertRule.objects.conditionally_subscribe_project_to_alert_rules + with patch( + "sentry.incidents.models.alert_rule.AlertRule.objects.conditionally_subscribe_project_to_alert_rules", + wraps=subscribe_project, + ) as wrapped_subscribe_project: + with self.tasks(): + rpe = ReleaseProjectEnvironmentManager.subscribe_project_to_alert_rule( + project=self.project, + release=self.release, + environment=self.environment, + trigger="test", + ) + + assert rpe + assert wrapped_subscribe_project.call_count == 1 + + queryset = QuerySubscription.objects.filter(project=self.project) + assert queryset.exists() + + sub = queryset.first() + assert sub.subscription_id is not None