diff --git a/src/sentry/seer/autofix/issue_summary.py b/src/sentry/seer/autofix/issue_summary.py index c7e5c2b3442c10..a1c7544113e971 100644 --- a/src/sentry/seer/autofix/issue_summary.py +++ b/src/sentry/seer/autofix/issue_summary.py @@ -291,7 +291,7 @@ def _is_issue_fixable(group: Group, fixability_score: float) -> bool: return False -def _run_automation( +def run_automation( group: Group, user: User | RpcUser | AnonymousUser, event: GroupEvent, @@ -403,7 +403,7 @@ def _generate_summary( if should_run_automation: try: - _run_automation(group, user, event, source) + run_automation(group, user, event, source) except Exception: logger.exception( "Error auto-triggering autofix from issue summary", extra={"group_id": group.id} diff --git a/src/sentry/tasks/autofix.py b/src/sentry/tasks/autofix.py index d59cf83f41b063..926b649fbc2dde 100644 --- a/src/sentry/tasks/autofix.py +++ b/src/sentry/tasks/autofix.py @@ -31,12 +31,12 @@ def check_autofix_status(run_id: int, organization_id: int) -> None: @instrumented_task( - name="sentry.tasks.autofix.start_seer_automation", + name="sentry.tasks.autofix.generate_summary_and_run_automation", namespace=ingest_errors_tasks, processing_deadline_duration=35, retry=Retry(times=1), ) -def start_seer_automation(group_id: int) -> None: +def generate_summary_and_run_automation(group_id: int) -> None: from sentry.seer.autofix.issue_summary import get_issue_summary group = Group.objects.get(id=group_id) @@ -60,3 +60,33 @@ def generate_issue_summary_only(group_id: int) -> None: get_issue_summary( group=group, source=SeerAutomationSource.POST_PROCESS, should_run_automation=False ) + # TODO: Generate fixability score here and check for it in run_automation around line 316 + # That will make sure that even after adding fixability here it's not re-triggered. + # Currently fixability will only be generated after 10 events when run_automation is called + + +@instrumented_task( + name="sentry.tasks.autofix.run_automation_only_task", + namespace=ingest_errors_tasks, + processing_deadline_duration=35, + retry=Retry(times=1), +) +def run_automation_only_task(group_id: int) -> None: + """ + Run automation directly for a group (assumes summary and fixability already exist). + Used for triage signals flow when event count >= 10 and summary exists. + """ + from django.contrib.auth.models import AnonymousUser + + from sentry.seer.autofix.issue_summary import run_automation + + group = Group.objects.get(id=group_id) + event = group.get_latest_event() + + if not event: + logger.warning("run_automation_only_task.no_event_found", extra={"group_id": group_id}) + return + + run_automation( + group=group, user=AnonymousUser(), event=event, source=SeerAutomationSource.POST_PROCESS + ) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 4b7452819278f0..7a3ae2e8d1ead7 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1595,33 +1595,100 @@ 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.autofix.constants import AutofixAutomationTuningSettings + from sentry.seer.autofix.issue_summary import ( + get_issue_summary_cache_key, + get_issue_summary_lock_key, + ) 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 + from sentry.tasks.autofix import ( + generate_issue_summary_only, + generate_summary_and_run_automation, + run_automation_only_task, + ) event = job["event"] group = event.group - # Only run on issues with no existing scan - TODO: Update condition for triage signals V0 - if group.seer_fixability_score is not None: - return + # Default behaviour + if not features.has("projects:triage-signals-v0", group.project): + # Only run on issues with no existing scan + if group.seer_fixability_score is not None: + return - if is_issue_eligible_for_seer_automation(group) is False: - return + if not is_issue_eligible_for_seer_automation(group): + return - # Don't run if there's already a task in progress for this issue - lock_key, lock_name = get_issue_summary_lock_key(group.id) - lock = locks.get(lock_key, duration=1, name=lock_name) - if lock.locked(): - return + # Don't run if there's already a task in progress for this issue + lock_key, lock_name = get_issue_summary_lock_key(group.id) + lock = locks.get(lock_key, duration=1, name=lock_name) + if lock.locked(): + return - if is_seer_scanner_rate_limited(group.project, group.organization): - return + if is_seer_scanner_rate_limited(group.project, group.organization): + return + + generate_summary_and_run_automation.delay(group.id) + else: + # Triage signals V0 behaviour + + # If event count < 10, only generate summary (no automation) + if group.times_seen_with_pending < 10: + # Check if summary exists in cache + cache_key = get_issue_summary_cache_key(group.id) + if cache.get(cache_key) is not None: + return + + # Early returns for eligibility checks (cheap checks first) + if not is_issue_eligible_for_seer_automation(group): + return + + # Atomically set cache to prevent duplicate summary generation + summary_dispatch_cache_key = f"seer-summary-dispatched:{group.id}" + if not cache.add(summary_dispatch_cache_key, True, timeout=30): + return # Another process already dispatched summary generation + + # Rate limit check must be last, after cache.add succeeds, to avoid wasting quota + if is_seer_scanner_rate_limited(group.project, group.organization): + return + + generate_issue_summary_only.delay(group.id) + else: + # Event count >= 10: run automation + # Long-term check to avoid re-running + if ( + group.seer_autofix_last_triggered is not None + or group.seer_fixability_score + is not None # TODO: Remove this once fixability is generated with generate_issue_summary_only + or group.project.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.OFF + ): + return + + # Early returns for eligibility checks (cheap checks first) + if not is_issue_eligible_for_seer_automation(group): + return - start_seer_automation.delay(group.id) + # Atomically set cache to prevent duplicate dispatches (returns False if key exists) + automation_dispatch_cache_key = f"seer-automation-dispatched:{group.id}" + if not cache.add(automation_dispatch_cache_key, True, timeout=300): + return # Another process already dispatched automation + + # Check if summary exists in cache + cache_key = get_issue_summary_cache_key(group.id) + if cache.get(cache_key) is not None: + # Summary exists, run automation directly + run_automation_only_task.delay(group.id) + else: + # Rate limit check before generating summary + if is_seer_scanner_rate_limited(group.project, group.organization): + return + + # No summary yet, generate summary + run automation in one go + generate_summary_and_run_automation.delay(group.id) GROUP_CATEGORY_POST_PROCESS_PIPELINE = { diff --git a/tests/sentry/seer/autofix/test_issue_summary.py b/tests/sentry/seer/autofix/test_issue_summary.py index a7b72b7eeb0363..6350c7abce40bc 100644 --- a/tests/sentry/seer/autofix/test_issue_summary.py +++ b/tests/sentry/seer/autofix/test_issue_summary.py @@ -18,8 +18,8 @@ _fetch_user_preference, _get_event, _get_stopping_point_from_fixability, - _run_automation, get_issue_summary, + run_automation, ) from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.seer.models import SummarizeIssueResponse, SummarizeIssueScores @@ -611,7 +611,7 @@ def test_get_issue_summary_with_web_vitals_issue( mock_trigger_autofix_task.assert_called_once() @patch("sentry.seer.autofix.issue_summary.get_seer_org_acknowledgement") - @patch("sentry.seer.autofix.issue_summary._run_automation") + @patch("sentry.seer.autofix.issue_summary.run_automation") @patch("sentry.seer.autofix.issue_summary._get_trace_tree_for_event") @patch("sentry.seer.autofix.issue_summary._call_seer") @patch("sentry.seer.autofix.issue_summary._get_event") @@ -623,7 +623,7 @@ def test_get_issue_summary_continues_when_automation_fails( mock_run_automation, mock_get_acknowledgement, ): - """Test that issue summary is still returned when _run_automation throws an exception.""" + """Test that issue summary is still returned when run_automation throws an exception.""" mock_get_acknowledgement.return_value = True # Set up event and seer response @@ -641,7 +641,7 @@ def test_get_issue_summary_continues_when_automation_fails( ) mock_call_seer.return_value = mock_summary - # Make _run_automation raise an exception + # Make run_automation raise an exception mock_run_automation.side_effect = Exception("Automation failed") # Call get_issue_summary and verify it still returns successfully @@ -652,7 +652,7 @@ def test_get_issue_summary_continues_when_automation_fails( expected_response["event_id"] = event.event_id assert summary_data == convert_dict_key_case(expected_response, snake_to_camel_case) - # Verify _run_automation was called and failed + # Verify run_automation was called and failed mock_run_automation.assert_called_once() mock_call_seer.assert_called_once() @@ -681,7 +681,7 @@ def test_get_issue_summary_handles_trace_tree_errors( possible_cause="cause", ), ) as mock_call_seer, - patch("sentry.seer.autofix.issue_summary._run_automation"), + patch("sentry.seer.autofix.issue_summary.run_automation"), patch( "sentry.seer.autofix.issue_summary.get_seer_org_acknowledgement", return_value=True, @@ -693,7 +693,7 @@ def test_get_issue_summary_handles_trace_tree_errors( mock_call_seer.assert_called_once_with(self.group, serialized_event, None) @patch("sentry.seer.autofix.issue_summary.get_seer_org_acknowledgement") - @patch("sentry.seer.autofix.issue_summary._run_automation") + @patch("sentry.seer.autofix.issue_summary.run_automation") @patch("sentry.seer.autofix.issue_summary._get_trace_tree_for_event") @patch("sentry.seer.autofix.issue_summary._call_seer") @patch("sentry.seer.autofix.issue_summary._get_event") @@ -705,7 +705,7 @@ def test_get_issue_summary_with_should_run_automation_false( mock_run_automation, mock_get_acknowledgement, ): - """Test that should_run_automation=False prevents _run_automation from being called.""" + """Test that should_run_automation=False prevents run_automation from being called.""" mock_get_acknowledgement.return_value = True event = Mock( event_id="test_event_id", @@ -743,7 +743,7 @@ def test_get_issue_summary_with_should_run_automation_false( mock_call_seer.assert_called_once_with(self.group, serialized_event, {"trace": "tree"}) mock_get_acknowledgement.assert_called_once_with(self.group.organization) - # Verify that _run_automation was NOT called + # Verify that run_automation was NOT called mock_run_automation.assert_not_called() # Check if the cache was set correctly @@ -798,7 +798,7 @@ def test_high_fixability_code_changes( possible_cause="c", scores=SummarizeIssueScores(fixability_score=0.70), ) - _run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) + run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) mock_trigger.assert_called_once() assert mock_trigger.call_args[1]["stopping_point"] == AutofixStoppingPoint.CODE_CHANGES @@ -822,7 +822,7 @@ def test_medium_fixability_solution( possible_cause="c", scores=SummarizeIssueScores(fixability_score=0.50), ) - _run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) + run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) mock_trigger.assert_called_once() assert mock_trigger.call_args[1]["stopping_point"] == AutofixStoppingPoint.SOLUTION @@ -848,7 +848,7 @@ def test_without_feature_flag(self, mock_gen, mock_budget, mock_state, mock_rate with self.feature( {"organizations:gen-ai-features": True, "projects:triage-signals-v0": False} ): - _run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) + run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) mock_trigger.assert_called_once() assert mock_trigger.call_args[1]["stopping_point"] is None @@ -1001,7 +1001,7 @@ def test_user_preference_limits_high_fixability( ) mock_fetch.return_value = "solution" - _run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) + run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) mock_trigger.assert_called_once() # Should be limited to SOLUTION by user preference @@ -1031,7 +1031,7 @@ def test_fixability_limits_permissive_user_preference( ) mock_fetch.return_value = "open_pr" - _run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) + run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) mock_trigger.assert_called_once() # Should use SOLUTION from fixability, not OPEN_PR from user @@ -1061,7 +1061,7 @@ def test_no_user_preference_uses_fixability_only( ) mock_fetch.return_value = None - _run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) + run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) mock_trigger.assert_called_once() # Should use OPEN_PR from fixability diff --git a/tests/sentry/tasks/test_post_process.py b/tests/sentry/tasks/test_post_process.py index 4fdaddaca4abf3..ae63fe9e4997cc 100644 --- a/tests/sentry/tasks/test_post_process.py +++ b/tests/sentry/tasks/test_post_process.py @@ -2675,10 +2675,10 @@ class KickOffSeerAutomationTestMixin(BasePostProgressGroupMixin): "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_kick_off_seer_automation_with_features( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( @@ -2693,15 +2693,15 @@ def test_kick_off_seer_automation_with_features( event=event, ) - mock_start_seer_automation.assert_called_once_with(event.group.id) + mock_generate_summary_and_run_automation.assert_called_once_with(event.group.id) @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") def test_kick_off_seer_automation_without_org_feature( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( @@ -2715,16 +2715,16 @@ def test_kick_off_seer_automation_without_org_feature( event=event, ) - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=False, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_kick_off_seer_automation_without_seer_enabled( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( @@ -2739,16 +2739,16 @@ def test_kick_off_seer_automation_without_seer_enabled( event=event, ) - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_kick_off_seer_automation_without_scanner_on( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( @@ -2764,16 +2764,16 @@ def test_kick_off_seer_automation_without_scanner_on( event=event, ) - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_kick_off_seer_automation_skips_existing_fixability_score( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( @@ -2793,16 +2793,16 @@ def test_kick_off_seer_automation_skips_existing_fixability_score( event=event, ) - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_kick_off_seer_automation_runs_with_missing_fixability_score( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( @@ -2821,16 +2821,16 @@ def test_kick_off_seer_automation_runs_with_missing_fixability_score( event=event, ) - mock_start_seer_automation.assert_called_once_with(group.id) + mock_generate_summary_and_run_automation.assert_called_once_with(group.id) @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_kick_off_seer_automation_skips_with_existing_fixability_score( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key @@ -2856,16 +2856,16 @@ def test_kick_off_seer_automation_skips_with_existing_fixability_score( event=event, ) - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() @patch("sentry.seer.autofix.utils.is_seer_scanner_rate_limited") @patch("sentry.quotas.backend.has_available_reserved_budget") @patch("sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner") - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_rate_limit_only_checked_after_all_other_checks_pass( self, - mock_start_seer_automation, + mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement, mock_has_budget, mock_is_rate_limited, @@ -2889,10 +2889,10 @@ def test_rate_limit_only_checked_after_all_other_checks_pass( event=event, ) mock_is_rate_limited.assert_called_once_with(event.project, event.group.organization) - mock_start_seer_automation.assert_called_once_with(event.group.id) + mock_generate_summary_and_run_automation.assert_called_once_with(event.group.id) mock_is_rate_limited.reset_mock() - mock_start_seer_automation.reset_mock() + mock_generate_summary_and_run_automation.reset_mock() # Test 2: When seer org acknowledgement fails, rate limit should NOT be checked mock_get_seer_org_acknowledgement.return_value = False @@ -2909,10 +2909,10 @@ def test_rate_limit_only_checked_after_all_other_checks_pass( event=event2, ) mock_is_rate_limited.assert_not_called() - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() mock_is_rate_limited.reset_mock() - mock_start_seer_automation.reset_mock() + mock_generate_summary_and_run_automation.reset_mock() mock_get_seer_org_acknowledgement.return_value = True # Reset to success # Test 3: When budget check fails, rate limit should NOT be checked @@ -2930,10 +2930,10 @@ def test_rate_limit_only_checked_after_all_other_checks_pass( event=event3, ) mock_is_rate_limited.assert_not_called() - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() mock_is_rate_limited.reset_mock() - mock_start_seer_automation.reset_mock() + mock_generate_summary_and_run_automation.reset_mock() mock_has_budget.return_value = True # Reset to success # Test 4: When project option is disabled, rate limit should NOT be checked @@ -2951,16 +2951,16 @@ def test_rate_limit_only_checked_after_all_other_checks_pass( event=event4, ) mock_is_rate_limited.assert_not_called() - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_kick_off_seer_automation_skips_when_lock_held( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): """Test that seer automation is skipped when another task is already processing the same issue""" from sentry.seer.autofix.issue_summary import get_issue_summary_lock_key @@ -2986,7 +2986,7 @@ def test_kick_off_seer_automation_skips_when_lock_held( ) # Verify that seer automation was NOT started due to the lock - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() # Test that it works normally when lock is not held event2 = self.create_event( @@ -3002,16 +3002,16 @@ def test_kick_off_seer_automation_skips_when_lock_held( ) # Now it should be called since no lock is held - mock_start_seer_automation.assert_called_once_with(event2.group.id) + mock_generate_summary_and_run_automation.assert_called_once_with(event2.group.id) @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_kick_off_seer_automation_with_hide_ai_features_enabled( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): """Test that seer automation is not started when organization has hideAiFeatures set to True""" self.project.update_option("sentry:seer_scanner_automation", True) @@ -3029,7 +3029,261 @@ def test_kick_off_seer_automation_with_hide_ai_features_enabled( event=event, ) - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() + + +class TriageSignalsV0TestMixin(BasePostProgressGroupMixin): + """Tests for the triage signals V0 flow behind the projects:triage-signals-v0 feature flag.""" + + @patch( + "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", + return_value=True, + ) + @patch("sentry.tasks.autofix.generate_issue_summary_only.delay") + @with_feature({"organizations:gen-ai-features": True, "projects:triage-signals-v0": True}) + def test_triage_signals_event_count_less_than_10_no_cache( + self, mock_generate_summary_only, mock_get_seer_org_acknowledgement + ): + """Test that with event count < 10 and no cached summary, we generate summary only (no automation).""" + self.project.update_option("sentry:seer_scanner_automation", True) + event = self.create_event( + data={"message": "testing"}, + project_id=self.project.id, + ) + + # Ensure event count < 10 + group = event.group + # Set times_seen_pending to 0 to ensure times_seen_with_pending < 10 + group.times_seen_pending = 0 + assert group.times_seen_with_pending < 10 + + self.call_post_process_group( + is_new=True, + is_regression=False, + is_new_group_environment=True, + event=event, + ) + + # Should call generate_issue_summary_only (not generate_summary_and_run_automation) + mock_generate_summary_only.assert_called_once_with(group.id) + + @patch( + "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", + return_value=True, + ) + @patch("sentry.tasks.autofix.generate_issue_summary_only.delay") + @with_feature({"organizations:gen-ai-features": True, "projects:triage-signals-v0": True}) + def test_triage_signals_event_count_less_than_10_with_cache( + self, mock_generate_summary_only, mock_get_seer_org_acknowledgement + ): + """Test that with event count < 10 and cached summary exists, we do nothing.""" + self.project.update_option("sentry:seer_scanner_automation", True) + event = self.create_event( + data={"message": "testing"}, + project_id=self.project.id, + ) + + # Cache a summary for this group + from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key + + group = event.group + cache_key = get_issue_summary_cache_key(group.id) + cache.set(cache_key, {"summary": "test summary"}, 3600) + + self.call_post_process_group( + is_new=True, + is_regression=False, + is_new_group_environment=True, + event=event, + ) + + # Should not call anything since summary exists + mock_generate_summary_only.assert_not_called() + + @patch( + "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", + return_value=True, + ) + @patch("sentry.tasks.autofix.run_automation_only_task.delay") + @with_feature({"organizations:gen-ai-features": True, "projects:triage-signals-v0": True}) + def test_triage_signals_event_count_gte_10_with_cache( + self, mock_run_automation, mock_get_seer_org_acknowledgement + ): + """Test that with event count >= 10 and cached summary exists, we run automation directly.""" + self.project.update_option("sentry:seer_scanner_automation", True) + self.project.update_option("sentry:autofix_automation_tuning", "always") + event = self.create_event( + data={"message": "testing"}, + project_id=self.project.id, + ) + + # Update group times_seen to simulate >= 10 events + group = event.group + group.times_seen = 1 + group.save() + # Also update the event's cached group reference + event.group.times_seen = 1 + + # Mock buffer backend to return pending increments + from sentry import buffer + + def mock_buffer_get(model, columns, filters): + return {"times_seen": 9} + + with patch.object(buffer.backend, "get", side_effect=mock_buffer_get): + # Cache a summary for this group + from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key + + cache_key = get_issue_summary_cache_key(group.id) + cache.set(cache_key, {"summary": "test summary"}, 3600) + + self.call_post_process_group( + is_new=False, + is_regression=False, + is_new_group_environment=False, + event=event, + ) + + # Should call run_automation_only_task since summary exists + mock_run_automation.assert_called_once_with(group.id) + + @patch( + "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", + return_value=True, + ) + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") + @with_feature({"organizations:gen-ai-features": True, "projects:triage-signals-v0": True}) + def test_triage_signals_event_count_gte_10_no_cache( + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement + ): + """Test that with event count >= 10 and no cached summary, we generate summary + run automation.""" + self.project.update_option("sentry:seer_scanner_automation", True) + self.project.update_option("sentry:autofix_automation_tuning", "always") + event = self.create_event( + data={"message": "testing"}, + project_id=self.project.id, + ) + + # Update group times_seen to simulate >= 10 events + group = event.group + group.times_seen = 1 + group.save() + # Also update the event's cached group reference + event.group.times_seen = 1 + + # Mock buffer backend to return pending increments + from sentry import buffer + + def mock_buffer_get(model, columns, filters): + return {"times_seen": 9} + + with patch.object(buffer.backend, "get", side_effect=mock_buffer_get): + self.call_post_process_group( + is_new=False, + is_regression=False, + is_new_group_environment=False, + event=event, + ) + + # Should call generate_summary_and_run_automation to generate summary + run automation + mock_generate_summary_and_run_automation.assert_called_once_with(group.id) + + @patch( + "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", + return_value=True, + ) + @patch("sentry.tasks.autofix.run_automation_only_task.delay") + @with_feature({"organizations:gen-ai-features": True, "projects:triage-signals-v0": True}) + def test_triage_signals_event_count_gte_10_skips_with_seer_last_triggered( + self, mock_run_automation, mock_get_seer_org_acknowledgement + ): + """Test that with event count >= 10 and seer_autofix_last_triggered set, we skip automation.""" + self.project.update_option("sentry:seer_scanner_automation", True) + event = self.create_event( + data={"message": "testing"}, + project_id=self.project.id, + ) + + # Update group times_seen and seer_autofix_last_triggered + group = event.group + group.times_seen = 1 + group.seer_autofix_last_triggered = timezone.now() + group.save() + # Also update the event's cached group reference + event.group.times_seen = 1 + event.group.seer_autofix_last_triggered = group.seer_autofix_last_triggered + + # Mock buffer backend to return pending increments + from sentry import buffer + + def mock_buffer_get(model, columns, filters): + return {"times_seen": 9} + + with patch.object(buffer.backend, "get", side_effect=mock_buffer_get): + # Cache a summary for this group + from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key + + cache_key = get_issue_summary_cache_key(group.id) + cache.set(cache_key, {"summary": "test summary"}, 3600) + + self.call_post_process_group( + is_new=False, + is_regression=False, + is_new_group_environment=False, + event=event, + ) + + # Should not call automation since seer_autofix_last_triggered is set + mock_run_automation.assert_not_called() + + @patch( + "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", + return_value=True, + ) + @patch("sentry.tasks.autofix.run_automation_only_task.delay") + @with_feature({"organizations:gen-ai-features": True, "projects:triage-signals-v0": True}) + def test_triage_signals_event_count_gte_10_skips_with_existing_fixability_score( + self, mock_run_automation, mock_get_seer_org_acknowledgement + ): + """Test that with event count >= 10 and seer_fixability_score exists, we skip automation.""" + self.project.update_option("sentry:seer_scanner_automation", True) + self.project.update_option("sentry:autofix_automation_tuning", "always") + event = self.create_event( + data={"message": "testing"}, + project_id=self.project.id, + ) + + # Update group times_seen and set seer_fixability_score (but not seer_autofix_last_triggered) + group = event.group + group.times_seen = 1 + group.seer_fixability_score = 0.5 + group.save() + # Also update the event's cached group reference + event.group.times_seen = 1 + event.group.seer_fixability_score = 0.5 + + # Mock buffer backend to return pending increments + from sentry import buffer + + def mock_buffer_get(model, columns, filters): + return {"times_seen": 9} + + with patch.object(buffer.backend, "get", side_effect=mock_buffer_get): + # Cache a summary for this group + from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key + + cache_key = get_issue_summary_cache_key(group.id) + cache.set(cache_key, {"summary": "test summary"}, 3600) + + self.call_post_process_group( + is_new=False, + is_regression=False, + is_new_group_environment=False, + event=event, + ) + + # Should not call automation since seer_fixability_score exists + mock_run_automation.assert_not_called() class SeerAutomationHelperFunctionsTestMixin(BasePostProgressGroupMixin): @@ -3105,6 +3359,7 @@ class PostProcessGroupErrorTest( InboxTestMixin, ResourceChangeBoundsTestMixin, KickOffSeerAutomationTestMixin, + TriageSignalsV0TestMixin, SeerAutomationHelperFunctionsTestMixin, RuleProcessorTestMixin, ServiceHooksTestMixin, @@ -3194,6 +3449,7 @@ class PostProcessGroupPerformanceTest( SnoozeTestSkipSnoozeMixin, PerformanceIssueTestCase, KickOffSeerAutomationTestMixin, + TriageSignalsV0TestMixin, ): def create_event(self, data, project_id, assert_no_errors=True): fingerprint = data["fingerprint"][0] if data.get("fingerprint") else "some_group" @@ -3313,6 +3569,7 @@ class PostProcessGroupGenericTest( RuleProcessorTestMixin, SnoozeTestMixin, KickOffSeerAutomationTestMixin, + TriageSignalsV0TestMixin, ): def create_event(self, data, project_id, assert_no_errors=True): data["type"] = "generic"