From 319461aaf744ad312e2bf0b11d00207a6f34d7b9 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Fri, 5 Dec 2025 13:11:26 -0800 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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