diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index deb848980c1926..31e0da5e47c6b5 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -11,6 +11,7 @@ from sentry import features, options, ratelimits from sentry.constants import DataCategory from sentry.issues.auto_source_code_config.code_mapping import get_sorted_code_mapping_configs +from sentry.models.group import Group from sentry.models.organization import Organization from sentry.models.project import Project from sentry.models.repository import Repository @@ -286,6 +287,54 @@ def is_seer_scanner_rate_limited(project: Project, organization: Organization) - return is_rate_limited +def is_issue_eligible_for_seer_automation(group: Group) -> bool: + """Check if Seer automation is allowed for a given group based on permissions and issue type.""" + from sentry import quotas + from sentry.issues.grouptype import GroupCategory + + # check currently supported issue categories for Seer + if group.issue_category not in [ + GroupCategory.ERROR, + GroupCategory.PERFORMANCE, + GroupCategory.MOBILE, + GroupCategory.FRONTEND, + GroupCategory.DB_QUERY, + GroupCategory.HTTP_CLIENT, + ] or group.issue_category in [ + GroupCategory.REPLAY, + GroupCategory.FEEDBACK, + ]: + return False + + if not features.has("organizations:gen-ai-features", group.organization): + return False + + gen_ai_allowed = not group.organization.get_option("sentry:hide_ai_features") + if not gen_ai_allowed: + return False + + project = group.project + if ( + not project.get_option("sentry:seer_scanner_automation") + and not group.issue_type.always_trigger_seer_automation + ): + return False + + from sentry.seer.seer_setup import get_seer_org_acknowledgement + + seer_enabled = get_seer_org_acknowledgement(group.organization) + if not seer_enabled: + return False + + has_budget: bool = quotas.backend.has_available_reserved_budget( + org_id=group.organization.id, data_category=DataCategory.SEER_SCANNER + ) + if not has_budget: + return False + + return True + + AUTOFIX_AUTOTRIGGED_RATE_LIMIT_OPTION_MULTIPLIERS = { AutofixAutomationTuningSettings.OFF: 5, AutofixAutomationTuningSettings.SUPER_LOW: 5, diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index cc64c249c51892..e4cabf90c055db 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1622,7 +1622,10 @@ def check_if_flags_sent(job: PostProcessJob) -> None: def kick_off_seer_automation(job: PostProcessJob) -> None: from sentry.seer.autofix.issue_summary import get_issue_summary_lock_key - from sentry.seer.seer_setup import get_seer_org_acknowledgement + from sentry.seer.autofix.utils import ( + is_issue_eligible_for_seer_automation, + is_seer_scanner_rate_limited, + ) from sentry.tasks.autofix import start_seer_automation event = job["event"] @@ -1632,39 +1635,7 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: if group.seer_fixability_score is not None: return - # check currently supported issue categories for Seer - if group.issue_category not in [ - GroupCategory.ERROR, - GroupCategory.PERFORMANCE, - GroupCategory.MOBILE, - GroupCategory.FRONTEND, - GroupCategory.DB_QUERY, - GroupCategory.HTTP_CLIENT, - ] or group.issue_category in [ - GroupCategory.REPLAY, - GroupCategory.FEEDBACK, - ]: - return - - if not features.has("organizations:gen-ai-features", group.organization): - return - - gen_ai_allowed = not group.organization.get_option("sentry:hide_ai_features") - if not gen_ai_allowed: - return - - project = group.project - if ( - not project.get_option("sentry:seer_scanner_automation") - and not group.issue_type.always_trigger_seer_automation - ): - return - - # Check if automation has already been queued or completed for this group - # seer_autofix_last_triggered is set when trigger_autofix is successfully started. - # Use cache with short TTL to hold lock for a short since it takes a few minutes to set seer_autofix_last_triggeredes - cache_key = f"seer_automation_queued:{group.id}" - if cache.get(cache_key) or group.seer_autofix_last_triggered is not None: + if is_issue_eligible_for_seer_automation(group) is False: return # Don't run if there's already a task in progress for this issue @@ -1673,27 +1644,7 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: if lock.locked(): return - seer_enabled = get_seer_org_acknowledgement(group.organization) - if not seer_enabled: - return - - from sentry import quotas - from sentry.constants import DataCategory - - has_budget: bool = quotas.backend.has_available_reserved_budget( - org_id=group.organization.id, data_category=DataCategory.SEER_SCANNER - ) - if not has_budget: - return - - from sentry.seer.autofix.utils import is_seer_scanner_rate_limited - - if is_seer_scanner_rate_limited(project, group.organization): - return - - # cache.add uses Redis SETNX which atomically sets the key only if it doesn't exist - # Returns False if another process already set the key, ensuring only one process proceeds - if not cache.add(cache_key, True, timeout=600): # 10 minute + if is_seer_scanner_rate_limited(group.project, group.organization): return start_seer_automation.delay(group.id) diff --git a/tests/sentry/tasks/test_post_process.py b/tests/sentry/tasks/test_post_process.py index 357424102c6e68..36f90a502ac84d 100644 --- a/tests/sentry/tasks/test_post_process.py +++ b/tests/sentry/tasks/test_post_process.py @@ -7,7 +7,7 @@ from hashlib import md5 from typing import Any from unittest import mock -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, PropertyMock, patch import pytest from django.db import router @@ -3053,137 +3053,70 @@ def test_kick_off_seer_automation_with_hide_ai_features_enabled( mock_start_seer_automation.assert_not_called() - @patch( - "sentry.seer.seer_setup.get_seer_org_acknowledgement", - return_value=True, - ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") - @with_feature("organizations:gen-ai-features") - def test_kick_off_seer_automation_skips_when_seer_autofix_last_triggered_set( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement - ): - """Test that automation is skipped when group.seer_autofix_last_triggered is already set""" - self.project.update_option("sentry:seer_scanner_automation", True) - event = self.create_event( - data={"message": "testing"}, - project_id=self.project.id, - ) - - # Set seer_autofix_last_triggered on the group to simulate autofix already triggered - group = event.group - group.seer_autofix_last_triggered = timezone.now() - group.save() - - self.call_post_process_group( - is_new=True, - is_regression=False, - is_new_group_environment=True, - event=event, - ) - mock_start_seer_automation.assert_not_called() +class SeerAutomationHelperFunctionsTestMixin(BasePostProgressGroupMixin): + """Unit tests for is_issue_eligible_for_seer_automation.""" - @patch( - "sentry.seer.seer_setup.get_seer_org_acknowledgement", - return_value=True, - ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") - @with_feature("organizations:gen-ai-features") - def test_kick_off_seer_automation_skips_when_cache_key_exists( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + @patch("sentry.quotas.backend.has_available_reserved_budget", return_value=True) + @patch("sentry.seer.seer_setup.get_seer_org_acknowledgement", return_value=True) + @patch("sentry.features.has", return_value=True) + def test_is_issue_eligible_for_seer_automation( + self, mock_features_has, mock_get_seer_org_acknowledgement, mock_has_budget ): - """Test that automation is skipped when cache key indicates it's already queued""" - self.project.update_option("sentry:seer_scanner_automation", True) - event = self.create_event( - data={"message": "testing"}, - project_id=self.project.id, - ) + """Test permission check with various failure conditions.""" + from sentry.constants import DataCategory + from sentry.issues.grouptype import GroupCategory + from sentry.seer.autofix.utils import is_issue_eligible_for_seer_automation - # Set cache key to simulate automation already queued - cache_key = f"seer_automation_queued:{event.group.id}" - cache.set(cache_key, True, timeout=600) - - self.call_post_process_group( - is_new=True, - is_regression=False, - is_new_group_environment=True, - event=event, - ) + self.project.update_option("sentry:seer_scanner_automation", True) + event = self.create_event(data={"message": "testing"}, project_id=self.project.id) + group = event.group - mock_start_seer_automation.assert_not_called() + # All conditions pass + assert is_issue_eligible_for_seer_automation(group) is True - # Cleanup - cache.delete(cache_key) + # Unsupported categories (using PropertyMock to mock the property) + with patch( + "sentry.models.group.Group.issue_category", new_callable=PropertyMock + ) as mock_category: + mock_category.return_value = GroupCategory.REPLAY + assert is_issue_eligible_for_seer_automation(group) is False - @patch( - "sentry.seer.seer_setup.get_seer_org_acknowledgement", - return_value=True, - ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") - @with_feature("organizations:gen-ai-features") - def test_kick_off_seer_automation_uses_atomic_cache_add( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement - ): - """Test that cache.add atomic operation prevents race conditions""" - self.project.update_option("sentry:seer_scanner_automation", True) - event = self.create_event( - data={"message": "testing"}, - project_id=self.project.id, - ) + mock_category.return_value = GroupCategory.FEEDBACK + assert is_issue_eligible_for_seer_automation(group) is False - cache_key = f"seer_automation_queued:{event.group.id}" + # Missing feature flag + mock_features_has.return_value = False + assert is_issue_eligible_for_seer_automation(group) is False - with patch("sentry.tasks.post_process.cache") as mock_cache: - # Simulate cache.get returning None (not in cache) - # but cache.add returning False (another process set it) - mock_cache.get.return_value = None - mock_cache.add.return_value = False + # Hide AI features enabled + mock_features_has.return_value = True + self.organization.update_option("sentry:hide_ai_features", True) + assert is_issue_eligible_for_seer_automation(group) is False + self.organization.update_option("sentry:hide_ai_features", False) - self.call_post_process_group( - is_new=True, - is_regression=False, - is_new_group_environment=True, - event=event, - ) + # Scanner disabled without always_trigger + self.project.update_option("sentry:seer_scanner_automation", False) + with patch.object(group.issue_type, "always_trigger_seer_automation", False): + assert is_issue_eligible_for_seer_automation(group) is False - # Should check cache but not call automation due to cache.add returning False - mock_cache.get.assert_called() - mock_cache.add.assert_called_once_with(cache_key, True, timeout=600) - mock_start_seer_automation.assert_not_called() + # Scanner disabled but always_trigger enabled + with patch.object(group.issue_type, "always_trigger_seer_automation", True): + assert is_issue_eligible_for_seer_automation(group) is True - @patch( - "sentry.seer.seer_setup.get_seer_org_acknowledgement", - return_value=True, - ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") - @with_feature("organizations:gen-ai-features") - def test_kick_off_seer_automation_proceeds_when_cache_add_succeeds( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement - ): - """Test that automation proceeds when cache.add succeeds (no race condition)""" + # Seer not acknowledged self.project.update_option("sentry:seer_scanner_automation", True) - event = self.create_event( - data={"message": "testing"}, - project_id=self.project.id, - ) - - # Ensure seer_autofix_last_triggered is not set - assert event.group.seer_autofix_last_triggered is None + mock_get_seer_org_acknowledgement.return_value = False + assert is_issue_eligible_for_seer_automation(group) is False - self.call_post_process_group( - is_new=True, - is_regression=False, - is_new_group_environment=True, - event=event, + # No budget + mock_get_seer_org_acknowledgement.return_value = True + mock_has_budget.return_value = False + assert is_issue_eligible_for_seer_automation(group) is False + mock_has_budget.assert_called_with( + org_id=group.organization.id, data_category=DataCategory.SEER_SCANNER ) - # Should successfully queue automation - mock_start_seer_automation.assert_called_once_with(event.group.id) - - # Cleanup - cache_key = f"seer_automation_queued:{event.group.id}" - cache.delete(cache_key) - class PostProcessGroupErrorTest( TestCase, @@ -3194,6 +3127,7 @@ class PostProcessGroupErrorTest( InboxTestMixin, ResourceChangeBoundsTestMixin, KickOffSeerAutomationTestMixin, + SeerAutomationHelperFunctionsTestMixin, RuleProcessorTestMixin, ServiceHooksTestMixin, SnoozeTestMixin,