diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index a330162484139c..9001c4616245da 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -173,6 +173,24 @@ def get_project_seer_preferences(project_id: int): raise SeerApiError(response.data.decode("utf-8"), response.status) +def has_project_connected_repos(organization_id: int, project_id: int) -> bool: + """ + Check if a project has connected repositories in Seer. + Results are cached for 60 minutes to minimize API calls. + """ + cache_key = f"seer-project-has-repos:{organization_id}:{project_id}" + cached_value = cache.get(cache_key) + + if cached_value is not None: + return cached_value + + project_preferences = get_project_seer_preferences(project_id) + has_repos = bool(project_preferences.code_mapping_repos) + + cache.set(cache_key, has_repos, timeout=60 * 60) # Cache for 1 hour + return has_repos + + def bulk_get_project_preferences(organization_id: int, project_ids: list[int]) -> dict[str, dict]: """Bulk fetch Seer project preferences. Returns dict mapping project ID (string) to preference dict.""" path = "/v1/project-preference/bulk" diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index c9bd4e841caa31..f5b94aa3da49d0 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1684,6 +1684,14 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: if not cache.add(automation_dispatch_cache_key, True, timeout=300): return # Another process already dispatched automation + # Check if project has connected repositories - requirement for new pricing + # Import here to avoid circular import: utils.py imports from code_mapping.py + # which triggers Django model loading before apps are ready + from sentry.seer.autofix.utils import has_project_connected_repos + + if not has_project_connected_repos(group.organization.id, group.project.id): + return + # Check if summary exists in cache cache_key = get_issue_summary_cache_key(group.id) if cache.get(cache_key) is not None: diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index 8fa503b83d5e86..ce5f6f85b18401 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -11,6 +11,7 @@ CodingAgentStatus, get_autofix_prompt, get_coding_agent_prompt, + has_project_connected_repos, is_issue_eligible_for_seer_automation, is_seer_seat_based_tier_enabled, ) @@ -403,3 +404,84 @@ def test_returns_cached_value(self): # Even without feature flags enabled, should return cached True result = is_seer_seat_based_tier_enabled(self.organization) assert result is True + + +class TestHasProjectConnectedRepos(TestCase): + """Test the has_project_connected_repos function.""" + + def setUp(self): + super().setUp() + self.organization = self.create_organization() + self.project = self.create_project(organization=self.organization) + + @patch("sentry.seer.autofix.utils.cache") + @patch("sentry.seer.autofix.utils.get_project_seer_preferences") + def test_returns_true_when_repos_exist(self, mock_get_preferences, mock_cache): + """Test returns True when project has connected repositories.""" + mock_cache.get.return_value = None + mock_preferences = Mock() + mock_preferences.code_mapping_repos = [ + {"provider": "github", "owner": "test", "name": "repo"} + ] + mock_get_preferences.return_value = mock_preferences + + result = has_project_connected_repos(self.organization.id, self.project.id) + + assert result is True + mock_cache.set.assert_called_once_with( + f"seer-project-has-repos:{self.organization.id}:{self.project.id}", + True, + timeout=60 * 60, + ) + + @patch("sentry.seer.autofix.utils.cache") + @patch("sentry.seer.autofix.utils.get_project_seer_preferences") + def test_returns_false_when_no_repos(self, mock_get_preferences, mock_cache): + """Test returns False when project has no connected repositories.""" + mock_cache.get.return_value = None + mock_preferences = Mock() + mock_preferences.code_mapping_repos = [] + mock_get_preferences.return_value = mock_preferences + + result = has_project_connected_repos(self.organization.id, self.project.id) + + assert result is False + mock_cache.set.assert_called_once_with( + f"seer-project-has-repos:{self.organization.id}:{self.project.id}", + False, + timeout=60 * 60, + ) + + @patch("sentry.seer.autofix.utils.cache") + @patch("sentry.seer.autofix.utils.get_project_seer_preferences") + def test_returns_cached_value_true(self, mock_get_preferences, mock_cache): + """Test returns cached True value without calling API.""" + mock_cache.get.return_value = True + + result = has_project_connected_repos(self.organization.id, self.project.id) + + assert result is True + mock_get_preferences.assert_not_called() + mock_cache.set.assert_not_called() + + @patch("sentry.seer.autofix.utils.cache") + @patch("sentry.seer.autofix.utils.get_project_seer_preferences") + def test_returns_cached_value_false(self, mock_get_preferences, mock_cache): + """Test returns cached False value without calling API.""" + mock_cache.get.return_value = False + + result = has_project_connected_repos(self.organization.id, self.project.id) + + assert result is False + mock_get_preferences.assert_not_called() + mock_cache.set.assert_not_called() + + @patch("sentry.seer.autofix.utils.cache") + @patch("sentry.seer.autofix.utils.get_project_seer_preferences") + def test_raises_on_api_error(self, mock_get_preferences, mock_cache): + """Test raises SeerApiError when API call fails.""" + mock_cache.get.return_value = None + mock_get_preferences.side_effect = SeerApiError("API Error", 500) + + with pytest.raises(SeerApiError): + has_project_connected_repos(self.organization.id, self.project.id) diff --git a/tests/sentry/tasks/test_post_process.py b/tests/sentry/tasks/test_post_process.py index 4c1dd311d89e74..5edb98590e211f 100644 --- a/tests/sentry/tasks/test_post_process.py +++ b/tests/sentry/tasks/test_post_process.py @@ -3108,12 +3108,16 @@ def test_triage_signals_event_count_less_than_10_with_cache( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) + @patch( + "sentry.seer.autofix.utils.has_project_connected_repos", + return_value=True, + ) @patch("sentry.tasks.autofix.run_automation_only_task.delay") @with_feature( {"organizations:gen-ai-features": True, "organizations:triage-signals-v0-org": True} ) def test_triage_signals_event_count_gte_10_with_cache( - self, mock_run_automation, mock_get_seer_org_acknowledgement + self, mock_run_automation, mock_has_repos, 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) @@ -3157,12 +3161,19 @@ def mock_buffer_get(model, columns, filters): "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) + @patch( + "sentry.seer.autofix.utils.has_project_connected_repos", + return_value=True, + ) @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature( {"organizations:gen-ai-features": True, "organizations:triage-signals-v0-org": True} ) def test_triage_signals_event_count_gte_10_no_cache( - self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement + self, + mock_generate_summary_and_run_automation, + mock_has_repos, + 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) @@ -3196,6 +3207,55 @@ def mock_buffer_get(model, columns, filters): # 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.seer.autofix.utils.has_project_connected_repos", + return_value=False, + ) + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") + @with_feature( + {"organizations:gen-ai-features": True, "organizations:triage-signals-v0-org": True} + ) + def test_triage_signals_event_count_gte_10_skips_without_connected_repos( + self, + mock_generate_summary_and_run_automation, + mock_has_repos, + mock_get_seer_org_acknowledgement, + ): + """Test that with event count >= 10 but no connected repos, 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 to simulate >= 10 events + group = event.group + group.times_seen = 1 + group.save() + 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 not call automation since no connected repos + mock_generate_summary_and_run_automation.assert_not_called() + @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True,