diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index a330162484139c..b694dee0cda64e 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -20,10 +20,10 @@ from sentry.net.http import connection_from_url from sentry.seer.autofix.constants import AutofixAutomationTuningSettings, AutofixStatus from sentry.seer.models import ( - PreferenceResponse, SeerApiError, SeerApiResponseValidationError, SeerPermissionError, + SeerRawPreferenceResponse, SeerRepoDefinition, ) from sentry.seer.signed_seer_api import make_signed_seer_api_request, sign_with_seer_secret @@ -142,7 +142,7 @@ class CodingAgentStateUpdateRequest(BaseModel): ) -def get_project_seer_preferences(project_id: int): +def get_project_seer_preferences(project_id: int) -> SeerRawPreferenceResponse: """ Fetch Seer project preferences from the Seer API. @@ -150,7 +150,7 @@ def get_project_seer_preferences(project_id: int): project_id: The project ID to fetch preferences for Returns: - PreferenceResponse object if successful, None otherwise + SeerRawPreferenceResponse object if successful """ path = "/v1/project-preference" body = orjson.dumps({"project_id": project_id}) @@ -166,13 +166,56 @@ def get_project_seer_preferences(project_id: int): if response.status == 200: try: result = orjson.loads(response.data) - return PreferenceResponse.validate(result) + return SeerRawPreferenceResponse.validate(result) except (pydantic.ValidationError, orjson.JSONDecodeError, UnicodeDecodeError) as e: raise SeerApiResponseValidationError(str(e)) from e 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 for Seer automation. + Checks Seer preferences first, then falls back to Sentry code mappings. + 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 + + has_repos = False + + try: + project_preferences = get_project_seer_preferences(project_id) + has_repos = bool( + project_preferences.preference and project_preferences.preference.repositories + ) + except (SeerApiError, SeerApiResponseValidationError): + pass + + if not has_repos: + # If it's the first autofix run of project we check code mapping. + try: + project = Project.objects.get(id=project_id) + has_repos = bool(get_autofix_repos_from_project_code_mappings(project)) + except Project.DoesNotExist: + pass + + logger.info( + "Checking if project has repositories connected", + extra={ + "org_id": organization_id, + "project_id": project_id, + "has_repos": has_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" @@ -384,14 +427,6 @@ def is_seer_seat_based_tier_enabled(organization: Organization) -> bool: has_seat_based_seer = features.has("organizations:seat-based-seer-enabled", organization) cache.set(cache_key, has_seat_based_seer, timeout=60 * 60 * 4) # 4 hours TTL - logger.info( - "Checking if seat-based Seer tier is enabled", - extra={ - "org_id": organization.id, - "org_slug": organization.slug, - "has_seat_based_seer": has_seat_based_seer, - }, - ) return has_seat_based_seer diff --git a/src/sentry/seer/models.py b/src/sentry/seer/models.py index 99cf27ba32c14e..80c58dcfc2ed26 100644 --- a/src/sentry/seer/models.py +++ b/src/sentry/seer/models.py @@ -85,7 +85,15 @@ class SeerProjectPreference(BaseModel): automation_handoff: SeerAutomationHandoffConfiguration | None = None +class SeerRawPreferenceResponse(BaseModel): + """Response model for Seer's /v1/project-preference endpoint.""" + + preference: SeerProjectPreference | None + + class PreferenceResponse(BaseModel): + """Response model used by ProjectSeerPreferencesEndpoint which adds code_mapping_repos.""" + preference: SeerProjectPreference | None code_mapping_repos: list[SeerRepoDefinition] diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index c9bd4e841caa31..b9ee96bb5d8508 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1684,6 +1684,13 @@ 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 + # 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..1f39a812860746 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,145 @@ 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_preference = Mock() + mock_preference.repositories = [{"provider": "github", "owner": "test", "name": "repo"}] + mock_response = Mock() + mock_response.preference = mock_preference + mock_get_preferences.return_value = mock_response + + 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.get_autofix_repos_from_project_code_mappings") + @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, mock_get_code_mappings + ): + """Test returns False when project has no connected repositories.""" + mock_cache.get.return_value = None + mock_preference = Mock() + mock_preference.repositories = [] + mock_response = Mock() + mock_response.preference = mock_preference + mock_get_preferences.return_value = mock_response + mock_get_code_mappings.return_value = [] + + 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.get_autofix_repos_from_project_code_mappings") + @patch("sentry.seer.autofix.utils.cache") + @patch("sentry.seer.autofix.utils.get_project_seer_preferences") + def test_returns_false_when_preference_is_none_and_no_code_mappings( + self, mock_get_preferences, mock_cache, mock_get_code_mappings + ): + """Test returns False when preference is None and no code mappings exist.""" + mock_cache.get.return_value = None + mock_response = Mock() + mock_response.preference = None + mock_get_preferences.return_value = mock_response + mock_get_code_mappings.return_value = [] + + 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.get_autofix_repos_from_project_code_mappings") + @patch("sentry.seer.autofix.utils.cache") + @patch("sentry.seer.autofix.utils.get_project_seer_preferences") + def test_falls_back_to_code_mappings_when_no_seer_preference( + self, mock_get_preferences, mock_cache, mock_get_code_mappings + ): + """Test falls back to code mappings when Seer has no preference.""" + mock_cache.get.return_value = None + mock_response = Mock() + mock_response.preference = None + mock_get_preferences.return_value = mock_response + mock_get_code_mappings.return_value = [ + {"provider": "github", "owner": "test", "name": "repo"} + ] + + result = has_project_connected_repos(self.organization.id, self.project.id) + + assert result is True + mock_get_code_mappings.assert_called_once() + 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_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.get_autofix_repos_from_project_code_mappings") + @patch("sentry.seer.autofix.utils.cache") + @patch("sentry.seer.autofix.utils.get_project_seer_preferences") + def test_falls_back_to_code_mappings_on_api_error( + self, mock_get_preferences, mock_cache, mock_get_code_mappings + ): + """Test falls back to code mappings when Seer API fails.""" + mock_cache.get.return_value = None + mock_get_preferences.side_effect = SeerApiError("API Error", 500) + mock_get_code_mappings.return_value = [ + {"provider": "github", "owner": "test", "name": "repo"} + ] + + result = has_project_connected_repos(self.organization.id, self.project.id) + + assert result is True + mock_get_code_mappings.assert_called_once() 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,