From 319461aaf744ad312e2bf0b11d00207a6f34d7b9 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Fri, 5 Dec 2025 13:11:26 -0800 Subject: [PATCH 01/10] feat(triage signals): Seer orgs on new pring need to have Github integration to use autofix --- .../seer/endpoints/group_autofix_setup_check.py | 11 ++++++++++- src/sentry/tasks/post_process.py | 5 +++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/sentry/seer/endpoints/group_autofix_setup_check.py b/src/sentry/seer/endpoints/group_autofix_setup_check.py index 3d650dc3a3ab97..9e2447329f8123 100644 --- a/src/sentry/seer/endpoints/group_autofix_setup_check.py +++ b/src/sentry/seer/endpoints/group_autofix_setup_check.py @@ -7,7 +7,7 @@ from django.conf import settings from rest_framework.response import Response -from sentry import quotas +from sentry import features, quotas from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint @@ -135,6 +135,15 @@ def get(self, request: Request, group: Group) -> Response: organization=org, project=group.project ) + # Customers on new pricing need to have github configured to use Autofix. + # Check if project has code mappings configured (feature flagged) + if integration_check is None and features.has( + "organizations:triage-signals-v0-org", org + ): + repos_from_mappings = get_autofix_repos_from_project_code_mappings(group.project) + if not repos_from_mappings: + integration_check = "code_mappings_missing" + write_integration_check = None if request.query_params.get("check_write_access", False): repos = get_repos_and_access(group.project, group.id) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 95ad76dfd66e5a..3dc7581e277d75 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1606,6 +1606,7 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: get_issue_summary_lock_key, ) from sentry.seer.autofix.utils import ( + get_autofix_repos_from_project_code_mappings, is_issue_eligible_for_seer_automation, is_seer_scanner_rate_limited, ) @@ -1683,6 +1684,10 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: if not is_issue_eligible_for_seer_automation(group): return + # Check if project has connected repositories - requirement for new pricing + if not get_autofix_repos_from_project_code_mappings(group.project): + return + # 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): From 050df12b0403e2fb6fa9d2b43835a46d0a3d2a37 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Fri, 5 Dec 2025 13:47:55 -0800 Subject: [PATCH 02/10] fixed tests --- tests/sentry/tasks/test_post_process.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/sentry/tasks/test_post_process.py b/tests/sentry/tasks/test_post_process.py index 4c1dd311d89e74..bc1276934b3ae4 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.get_autofix_repos_from_project_code_mappings", + return_value=[{"name": "test-repo"}], + ) @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_get_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.get_autofix_repos_from_project_code_mappings", + return_value=[{"name": "test-repo"}], + ) @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_get_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) From 02f768cb55f2537d602781db6c8ed9e6146311b2 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Mon, 8 Dec 2025 14:53:30 -0800 Subject: [PATCH 03/10] removed import --- src/sentry/seer/autofix/utils.py | 18 ++++ .../endpoints/group_autofix_setup_check.py | 11 +-- src/sentry/tasks/post_process.py | 11 +-- .../sentry/seer/autofix/test_autofix_utils.py | 82 +++++++++++++++++++ tests/sentry/tasks/test_post_process.py | 61 ++++++++++++-- 5 files changed, 162 insertions(+), 21 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index ecd3acfdb840b8..bd7d732a5ff873 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/seer/endpoints/group_autofix_setup_check.py b/src/sentry/seer/endpoints/group_autofix_setup_check.py index 9e2447329f8123..3d650dc3a3ab97 100644 --- a/src/sentry/seer/endpoints/group_autofix_setup_check.py +++ b/src/sentry/seer/endpoints/group_autofix_setup_check.py @@ -7,7 +7,7 @@ from django.conf import settings from rest_framework.response import Response -from sentry import features, quotas +from sentry import quotas from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint @@ -135,15 +135,6 @@ def get(self, request: Request, group: Group) -> Response: organization=org, project=group.project ) - # Customers on new pricing need to have github configured to use Autofix. - # Check if project has code mappings configured (feature flagged) - if integration_check is None and features.has( - "organizations:triage-signals-v0-org", org - ): - repos_from_mappings = get_autofix_repos_from_project_code_mappings(group.project) - if not repos_from_mappings: - integration_check = "code_mappings_missing" - write_integration_check = None if request.query_params.get("check_write_access", False): repos = get_repos_and_access(group.project, group.id) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 3dc7581e277d75..7af3ed5cd7e6dc 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1606,7 +1606,6 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: get_issue_summary_lock_key, ) from sentry.seer.autofix.utils import ( - get_autofix_repos_from_project_code_mappings, is_issue_eligible_for_seer_automation, is_seer_scanner_rate_limited, ) @@ -1684,15 +1683,17 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: if not is_issue_eligible_for_seer_automation(group): return - # Check if project has connected repositories - requirement for new pricing - if not get_autofix_repos_from_project_code_mappings(group.project): - return - # 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 project has connected repositories - requirement for new pricing + 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 bc1276934b3ae4..5edb98590e211f 100644 --- a/tests/sentry/tasks/test_post_process.py +++ b/tests/sentry/tasks/test_post_process.py @@ -3109,15 +3109,15 @@ def test_triage_signals_event_count_less_than_10_with_cache( return_value=True, ) @patch( - "sentry.seer.autofix.utils.get_autofix_repos_from_project_code_mappings", - return_value=[{"name": "test-repo"}], + "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_repos, 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) @@ -3162,8 +3162,8 @@ def mock_buffer_get(model, columns, filters): return_value=True, ) @patch( - "sentry.seer.autofix.utils.get_autofix_repos_from_project_code_mappings", - return_value=[{"name": "test-repo"}], + "sentry.seer.autofix.utils.has_project_connected_repos", + return_value=True, ) @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature( @@ -3172,7 +3172,7 @@ def mock_buffer_get(model, columns, filters): def test_triage_signals_event_count_gte_10_no_cache( self, mock_generate_summary_and_run_automation, - mock_get_repos, + mock_has_repos, mock_get_seer_org_acknowledgement, ): """Test that with event count >= 10 and no cached summary, we generate summary + run automation.""" @@ -3207,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, From c245e5da1e39678dfb137ccca3ce7d56ab93fdc6 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Mon, 8 Dec 2025 14:54:41 -0800 Subject: [PATCH 04/10] removed import --- src/sentry/tasks/post_process.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 7af3ed5cd7e6dc..415ee22f944c21 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -23,6 +23,7 @@ from sentry.replays.lib.event_linking import transform_event_for_linking_payload from sentry.replays.lib.kafka import initialize_replays_publisher from sentry.seer.autofix.constants import FixabilityScoreThresholds +from sentry.seer.autofix.utils import has_project_connected_repos from sentry.sentry_metrics.client import generic_metrics_backend from sentry.sentry_metrics.use_case_id_registry import UseCaseID from sentry.signals import event_processed, issue_unignored @@ -1689,8 +1690,6 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: return # Another process already dispatched automation # Check if project has connected repositories - requirement for new pricing - from sentry.seer.autofix.utils import has_project_connected_repos - if not has_project_connected_repos(group.organization.id, group.project.id): return From 6be54308f31148c9855e66568cdd68ddea2b62b9 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Mon, 8 Dec 2025 14:57:16 -0800 Subject: [PATCH 05/10] circular import --- src/sentry/tasks/post_process.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 415ee22f944c21..0f49c27a261caa 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -23,7 +23,6 @@ from sentry.replays.lib.event_linking import transform_event_for_linking_payload from sentry.replays.lib.kafka import initialize_replays_publisher from sentry.seer.autofix.constants import FixabilityScoreThresholds -from sentry.seer.autofix.utils import has_project_connected_repos from sentry.sentry_metrics.client import generic_metrics_backend from sentry.sentry_metrics.use_case_id_registry import UseCaseID from sentry.signals import event_processed, issue_unignored @@ -1690,6 +1689,10 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: 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 From 712b8c5588100d1f87c2a56baddd6540ca4113e0 Mon Sep 17 00:00:00 2001 From: Mihir-Mavalankar Date: Wed, 10 Dec 2025 14:05:17 -0800 Subject: [PATCH 06/10] Update src/sentry/tasks/post_process.py Co-authored-by: Rohan Agarwal <47861399+roaga@users.noreply.github.com> --- src/sentry/tasks/post_process.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index f5b94aa3da49d0..b9ee96bb5d8508 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1685,7 +1685,6 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: 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 From ac622494faa5bbb495d5e941ee0f77976a47e99a Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Wed, 10 Dec 2025 14:06:57 -0800 Subject: [PATCH 07/10] removed log --- src/sentry/seer/autofix/utils.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 9001c4616245da..c8b5f29812e762 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -402,14 +402,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 From df3324e196774eae7d77156231042f7a3ced8d84 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Wed, 10 Dec 2025 14:10:42 -0800 Subject: [PATCH 08/10] added log to function --- src/sentry/seer/autofix/utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index c8b5f29812e762..7d0f7501bc7797 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -186,6 +186,14 @@ def has_project_connected_repos(organization_id: int, project_id: int) -> bool: project_preferences = get_project_seer_preferences(project_id) has_repos = bool(project_preferences.code_mapping_repos) + logger.info( + "Checking if project has repositories connected in Seer", + 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 From 2540105fe1d8c5806667ee339ee0ad944dc7d937 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Wed, 10 Dec 2025 16:24:33 -0800 Subject: [PATCH 09/10] fixed the response object --- src/sentry/seer/autofix/utils.py | 10 +++--- src/sentry/seer/models.py | 8 +++++ .../sentry/seer/autofix/test_autofix_utils.py | 36 ++++++++++++++----- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 7d0f7501bc7797..25e093d4add4d0 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,7 +166,7 @@ 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 @@ -185,7 +185,7 @@ def has_project_connected_repos(organization_id: int, project_id: int) -> bool: return cached_value project_preferences = get_project_seer_preferences(project_id) - has_repos = bool(project_preferences.code_mapping_repos) + has_repos = bool(project_preferences.preference and project_preferences.preference.repositories) logger.info( "Checking if project has repositories connected in Seer", extra={ 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/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index ce5f6f85b18401..b72a100612a733 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -419,11 +419,11 @@ def setUp(self): 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 + 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) @@ -439,9 +439,29 @@ def test_returns_true_when_repos_exist(self, mock_get_preferences, mock_cache): 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 + mock_preference = Mock() + mock_preference.repositories = [] + 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 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_false_when_preference_is_none(self, mock_get_preferences, mock_cache): + """Test returns False when preference is None.""" + mock_cache.get.return_value = None + mock_response = Mock() + mock_response.preference = None + mock_get_preferences.return_value = mock_response result = has_project_connected_repos(self.organization.id, self.project.id) From 41d23f5408f49b5c061ed544f90cea03716920f7 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Wed, 10 Dec 2025 17:16:43 -0800 Subject: [PATCH 10/10] Added fallback to code mappings --- src/sentry/seer/autofix/utils.py | 25 +++++++-- .../sentry/seer/autofix/test_autofix_utils.py | 55 ++++++++++++++++--- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 25e093d4add4d0..b694dee0cda64e 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -175,7 +175,8 @@ def get_project_seer_preferences(project_id: int) -> SeerRawPreferenceResponse: def has_project_connected_repos(organization_id: int, project_id: int) -> bool: """ - Check if a project has connected repositories in Seer. + 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}" @@ -184,10 +185,26 @@ def has_project_connected_repos(organization_id: int, project_id: int) -> bool: if cached_value is not None: return cached_value - project_preferences = get_project_seer_preferences(project_id) - has_repos = bool(project_preferences.preference and project_preferences.preference.repositories) + 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 in Seer", + "Checking if project has repositories connected", extra={ "org_id": organization_id, "project_id": project_id, diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index b72a100612a733..1f39a812860746 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -434,9 +434,12 @@ def test_returns_true_when_repos_exist(self, mock_get_preferences, mock_cache): 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): + 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() @@ -444,6 +447,7 @@ def test_returns_false_when_no_repos(self, mock_get_preferences, mock_cache): 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) @@ -454,14 +458,18 @@ def test_returns_false_when_no_repos(self, mock_get_preferences, mock_cache): 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(self, mock_get_preferences, mock_cache): - """Test returns False when preference is None.""" + 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) @@ -472,6 +480,31 @@ def test_returns_false_when_preference_is_none(self, mock_get_preferences, mock_ 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): @@ -496,12 +529,20 @@ def test_returns_cached_value_false(self, mock_get_preferences, mock_cache): 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_raises_on_api_error(self, mock_get_preferences, mock_cache): - """Test raises SeerApiError when API call fails.""" + 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"} + ] - with pytest.raises(SeerApiError): - has_project_connected_repos(self.organization.id, self.project.id) + result = has_project_connected_repos(self.organization.id, self.project.id) + + assert result is True + mock_get_code_mappings.assert_called_once()