diff --git a/src/sentry/integrations/slack/message_builder/issues.py b/src/sentry/integrations/slack/message_builder/issues.py index 3f0c84029488..157d9febca1b 100644 --- a/src/sentry/integrations/slack/message_builder/issues.py +++ b/src/sentry/integrations/slack/message_builder/issues.py @@ -424,9 +424,13 @@ def __init__( self.has_mentions_read_scope = has_mentions_read_scope self.workflow_id = workflow_id self.link_decorator = link_decorator - self._has_autofix = SeerAutofixOperator.has_access( - organization=self.group.organization, entrypoint_key=SeerEntrypointKey.SLACK - ) and SeerAutofixOperator.can_trigger_autofix(group=self.group) + self._has_autofix = ( + not is_unfurl + and SeerAutofixOperator.has_access( + organization=self.group.organization, entrypoint_key=SeerEntrypointKey.SLACK + ) + and SeerAutofixOperator.can_trigger_autofix(group=self.group) + ) def get_title_block( self, diff --git a/src/sentry/seer/entrypoints/operator.py b/src/sentry/seer/entrypoints/operator.py index 730a7641ab01..990d19476af4 100644 --- a/src/sentry/seer/entrypoints/operator.py +++ b/src/sentry/seer/entrypoints/operator.py @@ -4,7 +4,7 @@ from taskbroker_client.retry import Retry from sentry import features, options -from sentry.constants import ENABLE_SEER_CODING_DEFAULT, DataCategory +from sentry.constants import ENABLE_SEER_CODING_DEFAULT from sentry.inbox.issue import record_issue_pr_ready_for_review from sentry.inbox.types import SYSTEM_ACTOR as INBOX_SYSTEM_ACTOR from sentry.inbox.types import InboxActor @@ -27,7 +27,14 @@ from sentry.seer.agent.on_completion_hook import AgentOnCompletionHook from sentry.seer.autofix.commit_author import commit_author_for_user from sentry.seer.autofix.constants import AutofixReferrer -from sentry.seer.autofix.utils import AutofixStoppingPoint, get_automation_handoff +from sentry.seer.autofix.exceptions import NoSeerQuotaException, SeerOutOfCreditsException +from sentry.seer.autofix.feature.dispatch import check_autofix_billing +from sentry.seer.autofix.utils import ( + AutofixStoppingPoint, + get_automation_handoff, + is_issue_category_eligible, +) +from sentry.seer.credits import SeerOperation from sentry.seer.entrypoints.cache import ( SeerOperatorAgentCache, SeerOperatorAutofixCache, @@ -161,22 +168,22 @@ def has_access( def can_trigger_autofix(cls, *, group: Group) -> bool: """ Checks if a group is permitted to trigger autofix. - Validates Seer access for the organization, the issue category, and autofix quota. + Validates Seer access for the organization, the issue category, and that the + organization can pay for the root cause step. """ - from sentry import quotas - from sentry.seer.autofix.utils import is_free_cohort_org, is_issue_category_eligible - - return ( - has_seer_access(group.organization) - and is_issue_category_eligible(group) - and ( - is_free_cohort_org(group.organization) - or quotas.backend.check_seer_quota( - org_id=group.organization.id, - data_category=DataCategory.SEER_AUTOFIX, - ) + if not has_seer_access(group.organization) or not is_issue_category_eligible(group): + return False + try: + check_autofix_billing( + group.organization, + SeerOperation.AUTOFIX_ROOT_CAUSE, + is_new_run=True, + # Free cohort orgs have no subscription, so the quota check always fails for them. + skip_quota_for_free_cohort=True, ) - ) + except (NoSeerQuotaException, SeerOutOfCreditsException): + return False + return True def trigger_autofix( self, @@ -209,7 +216,6 @@ def trigger_autofix_agent( trigger_autofix_agent, trigger_push_changes, ) - from sentry.seer.autofix.exceptions import NoSeerQuotaException, SeerOutOfCreditsException from sentry.seer.autofix.feature.models import FEATURE_ID as AUTOFIX_FEATURE_ID from sentry.seer.autofix.steps import AutofixStep diff --git a/tests/sentry/integrations/slack/test_message_builder.py b/tests/sentry/integrations/slack/test_message_builder.py index cbbce51b6a73..7ea2d0f9b7fa 100644 --- a/tests/sentry/integrations/slack/test_message_builder.py +++ b/tests/sentry/integrations/slack/test_message_builder.py @@ -7,6 +7,9 @@ import orjson from django.test import override_settings +from sentry_protos.billing.v1.services.seer_feature_billing.v1.endpoint_check_seer_credits_pb2 import ( + CheckSeerCreditsResponse, +) from sentry.grouping.grouptype import ErrorGroupType from sentry.integrations.messaging.message_builder import ( @@ -45,6 +48,7 @@ from sentry.testutils.cases import PerformanceIssueTestCase, TestCase from sentry.testutils.factories import EventType from sentry.testutils.helpers.datetime import before_now, freeze_time +from sentry.testutils.helpers.features import with_feature from sentry.testutils.helpers.notifications import create_workflow_notification_origin_for_rule from sentry.testutils.outbox import outbox_runner from sentry.testutils.silo import assume_test_silo_mode @@ -1027,6 +1031,32 @@ def test_autofix_button_hidden_when_no_other_actions(self, mock_quota: MagicMock blocks = SlackIssuesMessageBuilder(group, issue_details=True).build() assert not self._has_autofix_button(blocks) + @override_settings(SENTRY_SELF_HOSTED=False) + @with_feature(["organizations:seer-credit-checks", "organizations:token-billed-seer-enabled"]) + @patch( + "sentry.seer_credit.backend.check_seer_credits", + return_value=CheckSeerCreditsResponse(has_enough_credits=False), + ) + @patch("sentry.quotas.backend.check_seer_quota", return_value=True) + def test_autofix_button_hidden_without_seer_credits( + self, _mock_quota: MagicMock, mock_check_seer_credits: MagicMock + ) -> None: + group = self.create_group(project=self.project) + blocks = SlackIssuesMessageBuilder(group).build() + assert not self._has_autofix_button(blocks) + mock_check_seer_credits.assert_called_once() + + @override_settings(SENTRY_SELF_HOSTED=False) + @with_feature(["organizations:seer-credit-checks", "organizations:token-billed-seer-enabled"]) + @patch("sentry.seer_credit.backend.check_seer_credits") + @patch("sentry.quotas.backend.check_seer_quota", return_value=True) + def test_unfurl_skips_seer_credit_check( + self, _mock_quota: MagicMock, mock_check_seer_credits: MagicMock + ) -> None: + group = self.create_group(project=self.project) + SlackIssuesMessageBuilder(group, is_unfurl=True).build() + mock_check_seer_credits.assert_not_called() + class BuildGroupAttachmentReplaysTest(TestCase): @patch("sentry.models.group.Group.has_replays") diff --git a/tests/sentry/seer/entrypoints/test_operator.py b/tests/sentry/seer/entrypoints/test_operator.py index 7d985e2c714b..645719958db5 100644 --- a/tests/sentry/seer/entrypoints/test_operator.py +++ b/tests/sentry/seer/entrypoints/test_operator.py @@ -6,6 +6,9 @@ import pytest from django.test import override_settings from django.utils import timezone +from sentry_protos.billing.v1.services.seer_feature_billing.v1.endpoint_check_seer_credits_pb2 import ( + CheckSeerCreditsResponse, +) from fixtures.seer.webhooks import MOCK_RUN_ID from sentry.integrations.types import EventLifecycleOutcome, ExternalProviders @@ -59,6 +62,7 @@ from sentry.testutils.asserts import assert_failure_metric, assert_halt_metric from sentry.testutils.cases import TestCase from sentry.testutils.helpers.action_log import capture_action_log +from sentry.testutils.helpers.features import with_feature from sentry.testutils.helpers.options import override_options from sentry.types.activity import ActivityType from sentry.utils.locking import UnableToAcquireLock @@ -619,6 +623,47 @@ def test_can_trigger_autofix_returns_false_for_ineligible_category( def test_can_trigger_autofix_returns_false_without_quota(self, mock_quota: MagicMock) -> None: assert SeerAutofixOperator.can_trigger_autofix(group=self.group) is False + @with_feature(["organizations:seer-credit-checks", "organizations:token-billed-seer-enabled"]) + @patch( + "sentry.seer_credit.backend.check_seer_credits", + return_value=CheckSeerCreditsResponse(has_enough_credits=False), + ) + @patch("sentry.quotas.backend.check_seer_quota", return_value=True) + def test_can_trigger_autofix_returns_false_without_root_cause_credits( + self, _mock_quota: MagicMock, mock_check_seer_credits: MagicMock + ) -> None: + assert SeerAutofixOperator.can_trigger_autofix(group=self.group) is False + mock_check_seer_credits.assert_called_once() + + @patch("sentry.seer_credit.backend.check_seer_credits") + @patch("sentry.quotas.backend.check_seer_quota", return_value=True) + def test_can_trigger_autofix_skips_credit_check_when_flag_off( + self, _mock_quota: MagicMock, mock_check_seer_credits: MagicMock + ) -> None: + assert SeerAutofixOperator.can_trigger_autofix(group=self.group) is True + mock_check_seer_credits.assert_not_called() + + @with_feature(["organizations:seer-credit-checks", "organizations:token-billed-seer-enabled"]) + @patch( + "sentry.seer_credit.backend.check_seer_credits", + return_value=CheckSeerCreditsResponse(has_enough_credits=True), + ) + @patch("sentry.quotas.backend.check_seer_quota", return_value=False) + def test_can_trigger_autofix_skips_quota_check_for_token_billed_orgs( + self, mock_quota: MagicMock, mock_check_seer_credits: MagicMock + ) -> None: + assert SeerAutofixOperator.can_trigger_autofix(group=self.group) is True + mock_check_seer_credits.assert_called_once() + mock_quota.assert_not_called() + + @patch("sentry.seer.autofix.feature.dispatch.is_free_cohort_org", return_value=True) + @patch("sentry.quotas.backend.check_seer_quota", return_value=False) + def test_can_trigger_autofix_skips_quota_check_for_free_cohort_orgs( + self, mock_quota: MagicMock, _mock_free_cohort: MagicMock + ) -> None: + assert SeerAutofixOperator.can_trigger_autofix(group=self.group) is True + mock_quota.assert_not_called() + @patch.object(SeerAutofixOperator, "has_access", return_value=True) def test_seer_event_creates_activity_rca_completed(self, _mock_has_access: MagicMock) -> None: event_payload = {