From ace86691d0eed5e622327459ba66de0d7034eac4 Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Thu, 20 Nov 2025 23:44:23 +0700 Subject: [PATCH 01/10] feat(cursor-agent): add toggle for whether to create pr --- .../integrations/coding_agent/models.py | 1 + src/sentry/integrations/cursor/client.py | 4 +- src/sentry/seer/autofix/coding_agent.py | 9 + src/sentry/seer/autofix/utils.py | 40 +++++ .../endpoints/project_seer_preferences.py | 2 + .../sentry/integrations/cursor/test_client.py | 170 +++++++++++++----- .../test_project_seer_preferences.py | 136 ++++++++++++++ 7 files changed, 312 insertions(+), 50 deletions(-) diff --git a/src/sentry/integrations/coding_agent/models.py b/src/sentry/integrations/coding_agent/models.py index 3965d6d7eb455f..89191386f3ab89 100644 --- a/src/sentry/integrations/coding_agent/models.py +++ b/src/sentry/integrations/coding_agent/models.py @@ -7,3 +7,4 @@ class CodingAgentLaunchRequest(BaseModel): prompt: str repository: SeerRepoDefinition branch_name: str + auto_create_pr: bool = False diff --git a/src/sentry/integrations/cursor/client.py b/src/sentry/integrations/cursor/client.py index 8f8ffae1a62be3..dca1dc0690d9d1 100644 --- a/src/sentry/integrations/cursor/client.py +++ b/src/sentry/integrations/cursor/client.py @@ -42,7 +42,9 @@ def launch(self, webhook_url: str, request: CodingAgentLaunchRequest) -> CodingA ), webhook=CursorAgentLaunchRequestWebhook(url=webhook_url, secret=self.webhook_secret), target=CursorAgentLaunchRequestTarget( - autoCreatePr=True, branchName=request.branch_name, openAsCursorGithubApp=True + autoCreatePr=request.auto_create_pr, + branchName=request.branch_name, + openAsCursorGithubApp=True, ), ) diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index b536da7995462b..c09604547768ff 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -23,6 +23,7 @@ CodingAgentState, get_autofix_state, get_coding_agent_prompt, + get_project_seer_preferences, ) from sentry.seer.models import SeerApiError from sentry.seer.signed_seer_api import make_signed_seer_api_request @@ -202,6 +203,13 @@ def _launch_agents_for_repos( Dictionary with 'successes' and 'failures' lists """ + # Fetch project preferences to get auto_create_pr setting from automation_handoff + auto_create_pr = False + preference_response = get_project_seer_preferences(autofix_state.request.project_id) + if preference_response and preference_response.preference: + if preference_response.preference.automation_handoff: + auto_create_pr = preference_response.preference.automation_handoff.auto_create_pr + repos = set( _extract_repos_from_root_cause(autofix_state) if trigger_source == AutofixTriggerSource.ROOT_CAUSE @@ -269,6 +277,7 @@ def _launch_agents_for_repos( prompt=prompt, repository=repo, branch_name=sanitize_branch_name(autofix_state.request.issue["title"]), + auto_create_pr=auto_create_pr, ) try: diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 6e75e08806d70d..20431006a6394b 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -133,6 +133,46 @@ class CodingAgentStateUpdateRequest(BaseModel): ) +def get_project_seer_preferences(project_id: int): + """ + Fetch Seer project preferences from the Seer API. + + Args: + project_id: The project ID to fetch preferences for + + Returns: + PreferenceResponse object if successful, None otherwise + """ + from pydantic import ValidationError + + from sentry.seer.endpoints.project_seer_preferences import PreferenceResponse + + try: + path = "/v1/project-preference" + body = orjson.dumps({"project_id": project_id}) + + connection_pool = connection_from_url(settings.SEER_AUTOFIX_URL) + response = make_signed_seer_api_request( + connection_pool, + path, + body=body, + timeout=5, + ) + + if response.status == 200: + result = orjson.loads(response.data) + return PreferenceResponse.validate(result) + except (orjson.JSONDecodeError, ValidationError, KeyError, TypeError, ValueError) as e: + logger.warning( + "seer.get_project_preferences_failed", + extra={ + "project_id": project_id, + "error": str(e), + }, + ) + return None + + def get_autofix_repos_from_project_code_mappings(project: Project) -> list[dict]: if settings.SEER_AUTOFIX_FORCE_USE_REPOS: # This is for testing purposes only, for example in s4s we want to force the use of specific repo(s) diff --git a/src/sentry/seer/endpoints/project_seer_preferences.py b/src/sentry/seer/endpoints/project_seer_preferences.py index 7c391821b7acd4..04b1effb803ef4 100644 --- a/src/sentry/seer/endpoints/project_seer_preferences.py +++ b/src/sentry/seer/endpoints/project_seer_preferences.py @@ -61,6 +61,7 @@ class SeerAutomationHandoffConfigurationSerializer(CamelSnakeSerializer): required=True, ) integration_id = serializers.IntegerField(required=True) + auto_create_pr = serializers.BooleanField(required=False, default=False) class ProjectSeerPreferencesSerializer(CamelSnakeSerializer): @@ -79,6 +80,7 @@ class SeerAutomationHandoffConfiguration(BaseModel): handoff_point: AutofixHandoffPoint target: Literal["cursor_background_agent"] integration_id: int + auto_create_pr: bool = False class SeerProjectPreference(BaseModel): diff --git a/tests/sentry/integrations/cursor/test_client.py b/tests/sentry/integrations/cursor/test_client.py index 1431d7c1a286e6..3e95e17823413c 100644 --- a/tests/sentry/integrations/cursor/test_client.py +++ b/tests/sentry/integrations/cursor/test_client.py @@ -1,74 +1,146 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, patch +from sentry.integrations.coding_agent.models import CodingAgentLaunchRequest from sentry.integrations.cursor.client import CursorAgentClient +from sentry.seer.models import SeerRepoDefinition from sentry.testutils.cases import TestCase -class CursorClientTest(TestCase): +class CursorAgentClientTest(TestCase): + def setUp(self) -> None: + super().setUp() + self.api_key = "test_api_key" + self.webhook_secret = "test_webhook_secret" + self.client = CursorAgentClient(api_key=self.api_key, webhook_secret=self.webhook_secret) + self.webhook_url = "https://example.com/webhook" - def setUp(self): - self.integration = MagicMock() - # Avoid shadowing TestCase.client attribute used by Django - self.cursor_client = CursorAgentClient( - api_key="test_api_key_123", webhook_secret="test_webhook_secret" + self.repo_definition = SeerRepoDefinition( + integration_id="111", + provider="github", + owner="getsentry", + name="sentry", + external_id="123456", + branch_name="main", ) - @patch("sentry.integrations.cursor.client.CursorAgentClient.post") - def test_launch(self, mock_post): - from datetime import datetime + @patch.object(CursorAgentClient, "post") + def test_launch_with_auto_create_pr_true(self, mock_post: Mock) -> None: + """Test that launch() correctly passes auto_create_pr=True to the API""" + # Setup mock response + mock_response = Mock() + mock_response.json = { + "id": "agent_123", + "status": "running", + "name": "Test Agent", + "createdAt": "2023-01-01T00:00:00Z", + "source": { + "repository": "https://github.com/getsentry/sentry", + "ref": "main", + }, + "target": { + "url": "https://cursor.com/agent/123", + "autoCreatePr": True, + "branchName": "fix-bug-123", + }, + } + mock_post.return_value = mock_response - from sentry.integrations.coding_agent.models import CodingAgentLaunchRequest - from sentry.seer.autofix.utils import CodingAgentProviderType, CodingAgentStatus - from sentry.seer.models import SeerRepoDefinition + # Create launch request with auto_create_pr=True + request = CodingAgentLaunchRequest( + prompt="Fix this bug", + repository=self.repo_definition, + branch_name="fix-bug-123", + auto_create_pr=True, + ) - # Mock the response - mock_response = MagicMock() + # Launch the agent + self.client.launch(webhook_url=self.webhook_url, request=request) + + # Assert that post was called with correct parameters + mock_post.assert_called_once() + call_kwargs = mock_post.call_args[1] + + # Verify the payload contains autoCreatePr=True + payload = call_kwargs["data"] + assert payload["target"]["autoCreatePr"] is True + + @patch.object(CursorAgentClient, "post") + def test_launch_with_auto_create_pr_false(self, mock_post: Mock) -> None: + """Test that launch() correctly passes auto_create_pr=False to the API""" + # Setup mock response + mock_response = Mock() mock_response.json = { - "id": "test_session_123", - "name": "Test Session", + "id": "agent_123", "status": "running", - "createdAt": datetime.now().isoformat(), + "name": "Test Agent", + "createdAt": "2023-01-01T00:00:00Z", "source": { - "repository": "https://github.com/testorg/testrepo", + "repository": "https://github.com/getsentry/sentry", "ref": "main", }, "target": { - "url": "https://github.com/org/repo/pull/1", - "autoCreatePr": True, - "branchName": "fix-bug", + "url": "https://cursor.com/agent/123", + "autoCreatePr": False, + "branchName": "fix-bug-123", }, } mock_post.return_value = mock_response - # Create a launch request + # Create launch request with auto_create_pr=False request = CodingAgentLaunchRequest( - prompt="Fix the bug", - repository=SeerRepoDefinition( - integration_id="123", - provider="github", - owner="testorg", - name="testrepo", - external_id="456", - branch_name="main", - ), - branch_name="fix-bug", + prompt="Fix this bug", + repository=self.repo_definition, + branch_name="fix-bug-123", + auto_create_pr=False, ) - webhook_url = "https://sentry.io/webhook" - result = self.cursor_client.launch(webhook_url=webhook_url, request=request) + # Launch the agent + self.client.launch(webhook_url=self.webhook_url, request=request) - # Verify the API call + # Assert that post was called with correct parameters mock_post.assert_called_once() - call_args = mock_post.call_args - assert call_args[0][0] == "/v0/agents" - - # Verify headers - headers = call_args[1]["headers"] - assert headers["Authorization"] == "Bearer test_api_key_123" - assert headers["content-type"] == "application/json;charset=utf-8" - - # Verify result - assert result.id == "test_session_123" - assert result.status == CodingAgentStatus.RUNNING - assert result.provider == CodingAgentProviderType.CURSOR_BACKGROUND_AGENT - assert result.name == "Test Session" + call_kwargs = mock_post.call_args[1] + + # Verify the payload contains autoCreatePr=False + payload = call_kwargs["data"] + assert payload["target"]["autoCreatePr"] is False + + @patch.object(CursorAgentClient, "post") + def test_launch_default_auto_create_pr(self, mock_post: Mock) -> None: + """Test that launch() defaults auto_create_pr to False when not specified""" + # Setup mock response + mock_response = Mock() + mock_response.json = { + "id": "agent_123", + "status": "running", + "name": "Test Agent", + "createdAt": "2023-01-01T00:00:00Z", + "source": { + "repository": "https://github.com/getsentry/sentry", + "ref": "main", + }, + "target": { + "url": "https://cursor.com/agent/123", + "autoCreatePr": False, + "branchName": "fix-bug-123", + }, + } + mock_post.return_value = mock_response + + # Create launch request without specifying auto_create_pr (should default to False) + request = CodingAgentLaunchRequest( + prompt="Fix this bug", + repository=self.repo_definition, + branch_name="fix-bug-123", + ) + + # Launch the agent + self.client.launch(webhook_url=self.webhook_url, request=request) + + # Assert that post was called with correct parameters + mock_post.assert_called_once() + call_kwargs = mock_post.call_args[1] + + # Verify the payload contains autoCreatePr=False (the default) + payload = call_kwargs["data"] + assert payload["target"]["autoCreatePr"] is False diff --git a/tests/sentry/seer/endpoints/test_project_seer_preferences.py b/tests/sentry/seer/endpoints/test_project_seer_preferences.py index 3e29f624fe9813..aab31059b8205b 100644 --- a/tests/sentry/seer/endpoints/test_project_seer_preferences.py +++ b/tests/sentry/seer/endpoints/test_project_seer_preferences.py @@ -438,3 +438,139 @@ def test_get_with_automation_handoff( assert ( response.data["preference"]["automation_handoff"]["target"] == "cursor_background_agent" ) + + @patch("sentry.seer.endpoints.project_seer_preferences.requests.post") + def test_post_with_auto_create_pr_in_handoff_config(self, mock_post: MagicMock) -> None: + """Test that POST request correctly handles auto_create_pr in automation_handoff""" + # Setup the mock + mock_response = Mock() + mock_response.status_code = 200 + mock_post.return_value = mock_response + + # Request data with automation_handoff including auto_create_pr + request_data = { + "repositories": [ + { + "organization_id": self.org.id, + "integration_id": "111", + "provider": "github", + "owner": "getsentry", + "name": "sentry", + "external_id": "123456", + } + ], + "automation_handoff": { + "handoff_point": "root_cause", + "target": "cursor_background_agent", + "integration_id": 123, + "auto_create_pr": True, + }, + } + + # Make the request + response = self.client.post(self.url, data=request_data) + + # Assert the response is successful + assert response.status_code == 204 + + # Assert that the mock was called + mock_post.assert_called_once() + args, kwargs = mock_post.call_args + + # Verify the request body contains auto_create_pr in automation_handoff + body_dict = orjson.loads(kwargs["data"]) + assert "preference" in body_dict + preference = body_dict["preference"] + assert "automation_handoff" in preference + assert preference["automation_handoff"]["auto_create_pr"] is True + + @patch("sentry.seer.endpoints.project_seer_preferences.requests.post") + @patch( + "sentry.seer.endpoints.project_seer_preferences.get_autofix_repos_from_project_code_mappings", + return_value=[], + ) + def test_get_returns_auto_create_pr_in_handoff_config( + self, mock_get_autofix_repos: MagicMock, mock_post: MagicMock + ) -> None: + """Test that GET method correctly returns auto_create_pr in automation_handoff""" + from sentry.seer.endpoints.project_seer_preferences import ( + SeerAutomationHandoffConfiguration, + ) + + # Create preference with auto_create_pr in automation_handoff + project_preference_with_handoff = SeerProjectPreference( + organization_id=self.org.id, + project_id=self.project.id, + repositories=[self.repo_definition], + automation_handoff=SeerAutomationHandoffConfiguration( + handoff_point="root_cause", + target="cursor_background_agent", + integration_id=123, + auto_create_pr=True, + ), + ) + + response_data = PreferenceResponse( + preference=project_preference_with_handoff, code_mapping_repos=[] + ).dict() + + # Setup the mock + mock_response = Mock() + mock_response.json.return_value = response_data + mock_response.status_code = 200 + mock_post.return_value = mock_response + + # Make the request + response = self.client.get(self.url) + + # Assert the response + assert response.status_code == 200 + assert "preference" in response.data + assert "automation_handoff" in response.data["preference"] + assert response.data["preference"]["automation_handoff"]["auto_create_pr"] is True + + @patch("sentry.seer.endpoints.project_seer_preferences.requests.post") + def test_post_handoff_without_auto_create_pr_defaults_to_false( + self, mock_post: MagicMock + ) -> None: + """Test that when auto_create_pr is not specified in handoff, it defaults to False""" + # Setup the mock + mock_response = Mock() + mock_response.status_code = 200 + mock_post.return_value = mock_response + + # Request data with automation_handoff but without auto_create_pr + request_data = { + "repositories": [ + { + "organization_id": self.org.id, + "integration_id": "111", + "provider": "github", + "owner": "getsentry", + "name": "sentry", + "external_id": "123456", + } + ], + "automation_handoff": { + "handoff_point": "root_cause", + "target": "cursor_background_agent", + "integration_id": 123, + }, + } + + # Make the request + response = self.client.post(self.url, data=request_data) + + # Assert the response is successful + assert response.status_code == 204 + + # Assert that the mock was called + mock_post.assert_called_once() + args, kwargs = mock_post.call_args + + # Verify the request body contains auto_create_pr defaulted to False + body_dict = orjson.loads(kwargs["data"]) + assert "preference" in body_dict + preference = body_dict["preference"] + assert "automation_handoff" in preference + assert preference["automation_handoff"]["auto_create_pr"] is False From 85754ea702098f3c471347f5edb32aae90973c48 Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Fri, 21 Nov 2025 01:17:26 +0700 Subject: [PATCH 02/10] fix tests and typing --- .../test_organization_coding_agents.py | 33 +++++++++++++++++++ .../sentry/integrations/cursor/test_client.py | 10 +++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py index dc76bc8907ab07..fa836bdbf57575 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py @@ -549,6 +549,7 @@ class OrganizationCodingAgentsPostLaunchTest(BaseOrganizationCodingAgentsTest): @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") @patch( "sentry.integrations.services.integration.integration_service.get_organization_integration" ) @@ -557,6 +558,7 @@ def test_launches_coding_agent( self, mock_get_integration, mock_get_org_integration, + mock_get_preferences, mock_get_prompt, mock_get_autofix_state, mock_get_providers, @@ -565,6 +567,7 @@ def test_launches_coding_agent( # Mock coding agent providers to include github mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Test coding agent prompt" + mock_get_preferences.return_value = None mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -593,6 +596,7 @@ def test_launches_coding_agent( @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") @patch( "sentry.integrations.services.integration.integration_service.get_organization_integration" ) @@ -601,6 +605,7 @@ def test_launch_with_all_parameters( self, mock_get_integration, mock_get_org_integration, + mock_get_preferences, mock_get_prompt, mock_get_autofix_state, mock_get_providers, @@ -608,6 +613,7 @@ def test_launch_with_all_parameters( """Test POST endpoint with all launch parameters.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Test prompt for all parameters" + mock_get_preferences.return_value = None mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -663,6 +669,7 @@ def test_handles_launch_exception( @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") @patch( "sentry.integrations.services.integration.integration_service.get_organization_integration" ) @@ -671,6 +678,7 @@ def test_multi_repo_launch( self, mock_get_integration, mock_get_org_integration, + mock_get_preferences, mock_get_prompt, mock_get_autofix_state, mock_get_providers, @@ -678,6 +686,7 @@ def test_multi_repo_launch( """Test POST endpoint launches agents for multiple repositories.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Multi-repo test prompt" + mock_get_preferences.return_value = None mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -729,6 +738,7 @@ def test_multi_repo_launch( @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") @patch( "sentry.integrations.services.integration.integration_service.get_organization_integration" ) @@ -737,6 +747,7 @@ def test_repo_launch_error_continues_with_others( self, mock_get_integration, mock_get_org_integration, + mock_get_preferences, mock_get_prompt, mock_get_autofix_state, mock_get_providers, @@ -744,6 +755,7 @@ def test_repo_launch_error_continues_with_others( """Test POST endpoint continues with other repos when one repo fails.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Test prompt for repo launch error" + mock_get_preferences.return_value = None # Create mock installation that fails for first repo failing_installation = MagicMock(spec=MockCodingAgentInstallation) @@ -817,6 +829,7 @@ def test_repo_launch_error_continues_with_others( @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") @patch( "sentry.integrations.services.integration.integration_service.get_organization_integration" ) @@ -825,12 +838,14 @@ def test_all_repos_fail_returns_failures( self, mock_get_integration, mock_get_org_integration, + mock_get_preferences, mock_get_prompt, mock_get_autofix_state, mock_get_providers, ): """Test POST endpoint returns failures when all repos fail to launch.""" mock_get_providers.return_value = ["github"] + mock_get_preferences.return_value = None # Create mock installation that always fails failing_installation = MagicMock(spec=MockCodingAgentInstallation) @@ -896,6 +911,7 @@ def test_all_repos_fail_returns_failures( @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") @patch( "sentry.integrations.services.integration.integration_service.get_organization_integration" ) @@ -906,6 +922,7 @@ def test_seer_storage_failure_continues( mock_store_to_seer, mock_get_integration, mock_get_org_integration, + mock_get_preferences, mock_get_prompt, mock_get_autofix_state, mock_get_providers, @@ -913,6 +930,7 @@ def test_seer_storage_failure_continues( """Test POST endpoint continues when Seer storage fails.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Test prompt for seer storage failure" + mock_get_preferences.return_value = None mock_store_to_seer.return_value = False # Simulate Seer storage failure mock_rpc_integration = self._create_mock_rpc_integration() @@ -938,6 +956,7 @@ class OrganizationCodingAgentsPostTriggerSourceTest(BaseOrganizationCodingAgents @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") @patch( "sentry.integrations.services.integration.integration_service.get_organization_integration" ) @@ -946,6 +965,7 @@ def test_root_cause_trigger_source( self, mock_get_integration, mock_get_org_integration, + mock_get_preferences, mock_get_prompt, mock_get_autofix_state, mock_get_providers, @@ -953,6 +973,7 @@ def test_root_cause_trigger_source( """Test POST endpoint with root_cause trigger_source.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Root cause prompt" + mock_get_preferences.return_value = None mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -982,6 +1003,7 @@ def test_root_cause_trigger_source( @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") @patch( "sentry.integrations.services.integration.integration_service.get_organization_integration" ) @@ -990,6 +1012,7 @@ def test_root_cause_repos_extracted_and_deduped( self, mock_get_integration, mock_get_org_integration, + mock_get_preferences, mock_get_prompt, mock_get_autofix_state, mock_get_providers, @@ -997,6 +1020,7 @@ def test_root_cause_repos_extracted_and_deduped( """Root cause repos are extracted, de-duplicated, and used for launch.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Root cause prompt" + mock_get_preferences.return_value = None mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -1055,6 +1079,7 @@ def test_root_cause_repos_extracted_and_deduped( @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") @patch( "sentry.integrations.services.integration.integration_service.get_organization_integration" ) @@ -1063,6 +1088,7 @@ def test_root_cause_without_relevant_repos_falls_back_to_request_repos( self, mock_get_integration, mock_get_org_integration, + mock_get_preferences, mock_get_prompt, mock_get_autofix_state, mock_get_providers, @@ -1070,6 +1096,7 @@ def test_root_cause_without_relevant_repos_falls_back_to_request_repos( """If root cause has no relevant_repos, fallback to request repos path executes.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Root cause prompt" + mock_get_preferences.return_value = None mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -1122,6 +1149,7 @@ def test_root_cause_without_relevant_repos_falls_back_to_request_repos( @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") @patch( "sentry.integrations.services.integration.integration_service.get_organization_integration" ) @@ -1130,6 +1158,7 @@ def test_solution_trigger_source( self, mock_get_integration, mock_get_org_integration, + mock_get_preferences, mock_get_prompt, mock_get_autofix_state, mock_get_providers, @@ -1137,6 +1166,7 @@ def test_solution_trigger_source( """Test POST endpoint with solution trigger_source.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Solution prompt" + mock_get_preferences.return_value = None mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -1200,6 +1230,7 @@ def test_invalid_trigger_source( @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") @patch( "sentry.integrations.services.integration.integration_service.get_organization_integration" ) @@ -1208,6 +1239,7 @@ def test_prompt_not_available( self, mock_get_integration, mock_get_org_integration, + mock_get_preferences, mock_get_prompt, mock_get_autofix_state, mock_get_providers, @@ -1215,6 +1247,7 @@ def test_prompt_not_available( """Test POST endpoint when prompt is not available.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = None # Prompt not available + mock_get_preferences.return_value = None mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration diff --git a/tests/sentry/integrations/cursor/test_client.py b/tests/sentry/integrations/cursor/test_client.py index 3e95e17823413c..a4ba9cb0b9734c 100644 --- a/tests/sentry/integrations/cursor/test_client.py +++ b/tests/sentry/integrations/cursor/test_client.py @@ -11,7 +11,9 @@ def setUp(self) -> None: super().setUp() self.api_key = "test_api_key" self.webhook_secret = "test_webhook_secret" - self.client = CursorAgentClient(api_key=self.api_key, webhook_secret=self.webhook_secret) + self.cursor_client = CursorAgentClient( + api_key=self.api_key, webhook_secret=self.webhook_secret + ) self.webhook_url = "https://example.com/webhook" self.repo_definition = SeerRepoDefinition( @@ -54,7 +56,7 @@ def test_launch_with_auto_create_pr_true(self, mock_post: Mock) -> None: ) # Launch the agent - self.client.launch(webhook_url=self.webhook_url, request=request) + self.cursor_client.launch(webhook_url=self.webhook_url, request=request) # Assert that post was called with correct parameters mock_post.assert_called_once() @@ -95,7 +97,7 @@ def test_launch_with_auto_create_pr_false(self, mock_post: Mock) -> None: ) # Launch the agent - self.client.launch(webhook_url=self.webhook_url, request=request) + self.cursor_client.launch(webhook_url=self.webhook_url, request=request) # Assert that post was called with correct parameters mock_post.assert_called_once() @@ -135,7 +137,7 @@ def test_launch_default_auto_create_pr(self, mock_post: Mock) -> None: ) # Launch the agent - self.client.launch(webhook_url=self.webhook_url, request=request) + self.cursor_client.launch(webhook_url=self.webhook_url, request=request) # Assert that post was called with correct parameters mock_post.assert_called_once() From 4680a301d28630c6b4fcccb46c19dfec1f030d1c Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Fri, 21 Nov 2025 02:04:00 +0700 Subject: [PATCH 03/10] fix the error handling for this util --- src/sentry/seer/autofix/utils.py | 39 ++++++++++++-------------------- 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 20431006a6394b..b60e0c3d116ea5 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -17,6 +17,7 @@ from sentry.models.repository import Repository from sentry.net.http import connection_from_url from sentry.seer.autofix.constants import AutofixAutomationTuningSettings, AutofixStatus +from sentry.seer.endpoints.project_seer_preferences import PreferenceResponse from sentry.seer.models import SeerApiError, SeerPermissionError, SeerRepoDefinition from sentry.seer.signed_seer_api import make_signed_seer_api_request, sign_with_seer_secret from sentry.utils import json @@ -143,34 +144,22 @@ def get_project_seer_preferences(project_id: int): Returns: PreferenceResponse object if successful, None otherwise """ - from pydantic import ValidationError + path = "/v1/project-preference" + body = orjson.dumps({"project_id": project_id}) - from sentry.seer.endpoints.project_seer_preferences import PreferenceResponse - - try: - path = "/v1/project-preference" - body = orjson.dumps({"project_id": project_id}) + connection_pool = connection_from_url(settings.SEER_AUTOFIX_URL) + response = make_signed_seer_api_request( + connection_pool, + path, + body=body, + timeout=5, + ) - connection_pool = connection_from_url(settings.SEER_AUTOFIX_URL) - response = make_signed_seer_api_request( - connection_pool, - path, - body=body, - timeout=5, - ) + if response.status == 200: + result = orjson.loads(response.data) + return PreferenceResponse.validate(result) - if response.status == 200: - result = orjson.loads(response.data) - return PreferenceResponse.validate(result) - except (orjson.JSONDecodeError, ValidationError, KeyError, TypeError, ValueError) as e: - logger.warning( - "seer.get_project_preferences_failed", - extra={ - "project_id": project_id, - "error": str(e), - }, - ) - return None + raise SeerApiError(response.data.decode("utf-8"), response.status) def get_autofix_repos_from_project_code_mappings(project: Project) -> list[dict]: From 7e19b9fd50700db4435922bc23f2e86814948a8c Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Fri, 21 Nov 2025 22:46:49 +0700 Subject: [PATCH 04/10] move models to central point to fix circular dep --- src/sentry/seer/autofix/utils.py | 8 +++-- .../endpoints/project_seer_preferences.py | 29 +------------------ src/sentry/seer/models.py | 27 ++++++++++++++++- .../test_project_seer_preferences.py | 11 ++----- 4 files changed, 36 insertions(+), 39 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index b60e0c3d116ea5..937c190cb9d6ee 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -17,8 +17,12 @@ from sentry.models.repository import Repository from sentry.net.http import connection_from_url from sentry.seer.autofix.constants import AutofixAutomationTuningSettings, AutofixStatus -from sentry.seer.endpoints.project_seer_preferences import PreferenceResponse -from sentry.seer.models import SeerApiError, SeerPermissionError, SeerRepoDefinition +from sentry.seer.models import ( + PreferenceResponse, + SeerApiError, + SeerPermissionError, + SeerRepoDefinition, +) from sentry.seer.signed_seer_api import make_signed_seer_api_request, sign_with_seer_secret from sentry.utils import json from sentry.utils.outcomes import Outcome, track_outcome diff --git a/src/sentry/seer/endpoints/project_seer_preferences.py b/src/sentry/seer/endpoints/project_seer_preferences.py index 04b1effb803ef4..d59d2986f86247 100644 --- a/src/sentry/seer/endpoints/project_seer_preferences.py +++ b/src/sentry/seer/endpoints/project_seer_preferences.py @@ -1,13 +1,10 @@ from __future__ import annotations import logging -from enum import StrEnum -from typing import Literal import orjson import requests from django.conf import settings -from pydantic import BaseModel from rest_framework import serializers from rest_framework.request import Request from rest_framework.response import Response @@ -20,7 +17,7 @@ from sentry.models.project import Project from sentry.ratelimits.config import RateLimitConfig from sentry.seer.autofix.utils import get_autofix_repos_from_project_code_mappings -from sentry.seer.models import SeerRepoDefinition +from sentry.seer.models import PreferenceResponse, SeerProjectPreference from sentry.seer.signed_seer_api import sign_with_seer_secret from sentry.types.ratelimit import RateLimit, RateLimitCategory @@ -72,30 +69,6 @@ class ProjectSeerPreferencesSerializer(CamelSnakeSerializer): ) -class AutofixHandoffPoint(StrEnum): - ROOT_CAUSE = "root_cause" - - -class SeerAutomationHandoffConfiguration(BaseModel): - handoff_point: AutofixHandoffPoint - target: Literal["cursor_background_agent"] - integration_id: int - auto_create_pr: bool = False - - -class SeerProjectPreference(BaseModel): - organization_id: int - project_id: int - repositories: list[SeerRepoDefinition] - automated_run_stopping_point: str | None = None - automation_handoff: SeerAutomationHandoffConfiguration | None = None - - -class PreferenceResponse(BaseModel): - preference: SeerProjectPreference | None - code_mapping_repos: list[SeerRepoDefinition] - - @region_silo_endpoint class ProjectSeerPreferencesEndpoint(ProjectEndpoint): permission_classes = ( diff --git a/src/sentry/seer/models.py b/src/sentry/seer/models.py index 3268b41d8194cb..944f7a2d7f0553 100644 --- a/src/sentry/seer/models.py +++ b/src/sentry/seer/models.py @@ -1,4 +1,5 @@ -from typing import TypedDict +from enum import StrEnum +from typing import Literal, TypedDict from pydantic import BaseModel @@ -65,6 +66,30 @@ class SummarizePageWebVitalsResponse(BaseModel): suggested_investigations: list[PageWebVitalsInsight] +class AutofixHandoffPoint(StrEnum): + ROOT_CAUSE = "root_cause" + + +class SeerAutomationHandoffConfiguration(BaseModel): + handoff_point: AutofixHandoffPoint + target: Literal["cursor_background_agent"] + integration_id: int + auto_create_pr: bool = False + + +class SeerProjectPreference(BaseModel): + organization_id: int + project_id: int + repositories: list[SeerRepoDefinition] + automated_run_stopping_point: str | None = None + automation_handoff: SeerAutomationHandoffConfiguration | None = None + + +class PreferenceResponse(BaseModel): + preference: SeerProjectPreference | None + code_mapping_repos: list[SeerRepoDefinition] + + class SeerApiError(Exception): def __init__(self, message: str, status: int): self.message = message diff --git a/tests/sentry/seer/endpoints/test_project_seer_preferences.py b/tests/sentry/seer/endpoints/test_project_seer_preferences.py index aab31059b8205b..29eedb716abd11 100644 --- a/tests/sentry/seer/endpoints/test_project_seer_preferences.py +++ b/tests/sentry/seer/endpoints/test_project_seer_preferences.py @@ -5,8 +5,7 @@ from django.conf import settings from django.urls import reverse -from sentry.seer.endpoints.project_seer_preferences import PreferenceResponse, SeerProjectPreference -from sentry.seer.models import SeerRepoDefinition +from sentry.seer.models import PreferenceResponse, SeerProjectPreference, SeerRepoDefinition from sentry.testutils.cases import APITestCase @@ -401,9 +400,7 @@ def test_get_with_automation_handoff( self, mock_get_autofix_repos: MagicMock, mock_post: MagicMock ) -> None: """Test that GET method correctly returns automation_handoff in the response""" - from sentry.seer.endpoints.project_seer_preferences import ( - SeerAutomationHandoffConfiguration, - ) + from sentry.seer.models import SeerAutomationHandoffConfiguration # Create preference with automation_handoff project_preference_with_handoff = SeerProjectPreference( @@ -493,9 +490,7 @@ def test_get_returns_auto_create_pr_in_handoff_config( self, mock_get_autofix_repos: MagicMock, mock_post: MagicMock ) -> None: """Test that GET method correctly returns auto_create_pr in automation_handoff""" - from sentry.seer.endpoints.project_seer_preferences import ( - SeerAutomationHandoffConfiguration, - ) + from sentry.seer.models import SeerAutomationHandoffConfiguration # Create preference with auto_create_pr in automation_handoff project_preference_with_handoff = SeerProjectPreference( From 5891078656b0caddd4ecc2a7af8b08047b47092b Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Fri, 21 Nov 2025 23:20:22 +0700 Subject: [PATCH 05/10] default to false on error case --- src/sentry/seer/autofix/coding_agent.py | 18 +- .../sentry/seer/autofix/test_coding_agent.py | 235 ++++++++++++++++++ 2 files changed, 249 insertions(+), 4 deletions(-) create mode 100644 tests/sentry/seer/autofix/test_coding_agent.py diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index c09604547768ff..438e68d5f0c22e 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -205,10 +205,20 @@ def _launch_agents_for_repos( # Fetch project preferences to get auto_create_pr setting from automation_handoff auto_create_pr = False - preference_response = get_project_seer_preferences(autofix_state.request.project_id) - if preference_response and preference_response.preference: - if preference_response.preference.automation_handoff: - auto_create_pr = preference_response.preference.automation_handoff.auto_create_pr + try: + preference_response = get_project_seer_preferences(autofix_state.request.project_id) + if preference_response and preference_response.preference: + if preference_response.preference.automation_handoff: + auto_create_pr = preference_response.preference.automation_handoff.auto_create_pr + except SeerApiError: + logger.exception( + "coding_agent.get_project_seer_preferences_error", + extra={ + "organization_id": organization.id, + "run_id": run_id, + "project_id": autofix_state.request.project_id, + }, + ) repos = set( _extract_repos_from_root_cause(autofix_state) diff --git a/tests/sentry/seer/autofix/test_coding_agent.py b/tests/sentry/seer/autofix/test_coding_agent.py new file mode 100644 index 00000000000000..fa3a8af408fbd6 --- /dev/null +++ b/tests/sentry/seer/autofix/test_coding_agent.py @@ -0,0 +1,235 @@ +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +from sentry.seer.autofix.coding_agent import _launch_agents_for_repos +from sentry.seer.autofix.utils import AutofixRequest, AutofixState, AutofixTriggerSource +from sentry.seer.models import SeerApiError, SeerRepoDefinition +from sentry.testutils.cases import TestCase + + +class TestLaunchAgentsForRepos(TestCase): + def setUp(self): + super().setUp() + self.organization = self.create_organization() + self.project = self.create_project(organization=self.organization) + self.run_id = 12345 + + # Create a basic autofix state with a solution that references a repo + self.autofix_state = AutofixState( + run_id=self.run_id, + request=AutofixRequest( + organization_id=self.organization.id, + project_id=self.project.id, + issue={"id": 1, "title": "Test Issue"}, + repos=[ + SeerRepoDefinition( + provider="github", + owner="getsentry", + name="sentry", + external_id="123456", + ) + ], + ), + updated_at=datetime.now(UTC), + status="COMPLETED", + steps=[ + { + "key": "solution", + "solution": [ + { + "relevant_code_file": { + "repo_name": "getsentry/sentry", + "file_path": "test.py", + } + } + ], + } + ], + ) + + @patch("sentry.seer.autofix.coding_agent.store_coding_agent_states_to_seer") + @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") + def test_auto_create_pr_defaults_to_false_on_seer_api_error( + self, mock_get_preferences, mock_get_prompt, mock_store_states + ): + """Test that auto_create_pr defaults to False when get_project_seer_preferences raises SeerApiError.""" + # Setup: Mock get_project_seer_preferences to raise SeerApiError + mock_get_preferences.side_effect = SeerApiError("API Error", 500) + + # Mock the prompt response + mock_get_prompt.return_value = "Test prompt" + + # Mock the installation and its launch method + mock_installation = MagicMock() + mock_installation.launch.return_value = { + "url": "https://example.com/agent", + "id": "agent-123", + } + + # Call the function + _launch_agents_for_repos( + installation=mock_installation, + autofix_state=self.autofix_state, + run_id=self.run_id, + organization=self.organization, + trigger_source=AutofixTriggerSource.SOLUTION, + ) + + # Assert: Verify that launch was called with auto_create_pr=False + assert mock_installation.launch.called + launch_request = mock_installation.launch.call_args[0][0] + assert launch_request.auto_create_pr is False + + # Verify that get_project_seer_preferences was called + mock_get_preferences.assert_called_once_with(self.project.id) + + @patch("sentry.seer.autofix.coding_agent.store_coding_agent_states_to_seer") + @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") + def test_auto_create_pr_uses_preference_when_available( + self, mock_get_preferences, mock_get_prompt, mock_store_states + ): + """Test that auto_create_pr uses the preference value when available.""" + from sentry.seer.models import ( + AutofixHandoffPoint, + PreferenceResponse, + SeerAutomationHandoffConfiguration, + SeerProjectPreference, + ) + + # Setup: Mock get_project_seer_preferences to return a preference with auto_create_pr=True + preference = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[ + SeerRepoDefinition( + provider="github", + owner="getsentry", + name="sentry", + external_id="123456", + ) + ], + automation_handoff=SeerAutomationHandoffConfiguration( + handoff_point=AutofixHandoffPoint.ROOT_CAUSE, + target="cursor_background_agent", + integration_id=123, + auto_create_pr=True, + ), + ) + mock_get_preferences.return_value = PreferenceResponse( + preference=preference, code_mapping_repos=[] + ) + + # Mock the prompt response + mock_get_prompt.return_value = "Test prompt" + + # Mock the installation and its launch method + mock_installation = MagicMock() + mock_installation.launch.return_value = { + "url": "https://example.com/agent", + "id": "agent-123", + } + + # Call the function + _launch_agents_for_repos( + installation=mock_installation, + autofix_state=self.autofix_state, + run_id=self.run_id, + organization=self.organization, + trigger_source=AutofixTriggerSource.SOLUTION, + ) + + # Assert: Verify that launch was called with auto_create_pr=True + assert mock_installation.launch.called + launch_request = mock_installation.launch.call_args[0][0] + assert launch_request.auto_create_pr is True + + @patch("sentry.seer.autofix.coding_agent.store_coding_agent_states_to_seer") + @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") + def test_auto_create_pr_defaults_to_false_when_no_preference( + self, mock_get_preferences, mock_get_prompt, mock_store_states + ): + """Test that auto_create_pr defaults to False when preference is None.""" + from sentry.seer.models import PreferenceResponse + + # Setup: Mock get_project_seer_preferences to return None preference + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) + + # Mock the prompt response + mock_get_prompt.return_value = "Test prompt" + + # Mock the installation and its launch method + mock_installation = MagicMock() + mock_installation.launch.return_value = { + "url": "https://example.com/agent", + "id": "agent-123", + } + + # Call the function + _launch_agents_for_repos( + installation=mock_installation, + autofix_state=self.autofix_state, + run_id=self.run_id, + organization=self.organization, + trigger_source=AutofixTriggerSource.SOLUTION, + ) + + # Assert: Verify that launch was called with auto_create_pr=False + assert mock_installation.launch.called + launch_request = mock_installation.launch.call_args[0][0] + assert launch_request.auto_create_pr is False + + @patch("sentry.seer.autofix.coding_agent.store_coding_agent_states_to_seer") + @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") + def test_auto_create_pr_defaults_to_false_when_no_automation_handoff( + self, mock_get_preferences, mock_get_prompt, mock_store_states + ): + """Test that auto_create_pr defaults to False when automation_handoff is None.""" + from sentry.seer.models import PreferenceResponse, SeerProjectPreference + + # Setup: Mock get_project_seer_preferences to return preference without automation_handoff + preference = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[ + SeerRepoDefinition( + provider="github", + owner="getsentry", + name="sentry", + external_id="123456", + ) + ], + automation_handoff=None, + ) + mock_get_preferences.return_value = PreferenceResponse( + preference=preference, code_mapping_repos=[] + ) + + # Mock the prompt response + mock_get_prompt.return_value = "Test prompt" + + # Mock the installation and its launch method + mock_installation = MagicMock() + mock_installation.launch.return_value = { + "url": "https://example.com/agent", + "id": "agent-123", + } + + # Call the function + _launch_agents_for_repos( + installation=mock_installation, + autofix_state=self.autofix_state, + run_id=self.run_id, + organization=self.organization, + trigger_source=AutofixTriggerSource.SOLUTION, + ) + + # Assert: Verify that launch was called with auto_create_pr=False + assert mock_installation.launch.called + launch_request = mock_installation.launch.call_args[0][0] + assert launch_request.auto_create_pr is False From d6e51f705c077ad939c41191f1463c9cb4c2e2dc Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Mon, 24 Nov 2025 21:57:47 +0700 Subject: [PATCH 06/10] retry and error catching --- src/sentry/seer/autofix/coding_agent.py | 3 ++- src/sentry/seer/autofix/utils.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index 438e68d5f0c22e..f4061508c6e22d 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -8,6 +8,7 @@ from django.conf import settings from requests import HTTPError from rest_framework.exceptions import APIException, NotFound, PermissionDenied, ValidationError +from urllib3.exceptions import MaxRetryError, TimeoutError from sentry import features from sentry.constants import ObjectStatus @@ -210,7 +211,7 @@ def _launch_agents_for_repos( if preference_response and preference_response.preference: if preference_response.preference.automation_handoff: auto_create_pr = preference_response.preference.automation_handoff.auto_create_pr - except SeerApiError: + except (SeerApiError, TimeoutError, MaxRetryError): logger.exception( "coding_agent.get_project_seer_preferences_error", extra={ diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 937c190cb9d6ee..15463a61499e9f 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -7,6 +7,7 @@ import requests from django.conf import settings from pydantic import BaseModel +from urllib3 import Retry from sentry import features, options, ratelimits from sentry.constants import DataCategory @@ -151,12 +152,12 @@ def get_project_seer_preferences(project_id: int): path = "/v1/project-preference" body = orjson.dumps({"project_id": project_id}) - connection_pool = connection_from_url(settings.SEER_AUTOFIX_URL) response = make_signed_seer_api_request( - connection_pool, + autofix_connection_pool, path, body=body, timeout=5, + retry=Retry(total=2, backoff_factor=0.5), ) if response.status == 200: From 88f7c42509dd98b363413af49d5de564ebd484c3 Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Tue, 25 Nov 2025 00:08:38 +0700 Subject: [PATCH 07/10] fix agent highlighted issues --- src/sentry/seer/autofix/coding_agent.py | 3 ++- src/sentry/seer/autofix/utils.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index f4061508c6e22d..93b0c1c4aa2c2d 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -5,6 +5,7 @@ import string import orjson +import pydantic from django.conf import settings from requests import HTTPError from rest_framework.exceptions import APIException, NotFound, PermissionDenied, ValidationError @@ -211,7 +212,7 @@ def _launch_agents_for_repos( if preference_response and preference_response.preference: if preference_response.preference.automation_handoff: auto_create_pr = preference_response.preference.automation_handoff.auto_create_pr - except (SeerApiError, TimeoutError, MaxRetryError): + except (SeerApiError, TimeoutError, MaxRetryError, pydantic.ValidationError): logger.exception( "coding_agent.get_project_seer_preferences_error", extra={ diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 15463a61499e9f..30b7ef46003a29 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -157,7 +157,7 @@ def get_project_seer_preferences(project_id: int): path, body=body, timeout=5, - retry=Retry(total=2, backoff_factor=0.5), + retries=Retry(total=2, backoff_factor=0.5), ) if response.status == 200: From edb8825bec88383a244749b421aa4292109f3c2f Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Tue, 25 Nov 2025 02:38:57 +0700 Subject: [PATCH 08/10] have a specific error for this --- src/sentry/seer/autofix/coding_agent.py | 6 ++---- src/sentry/seer/autofix/utils.py | 9 +++++++-- src/sentry/seer/models.py | 8 ++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index 93b0c1c4aa2c2d..fe75c3243b4ae0 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -5,11 +5,9 @@ import string import orjson -import pydantic from django.conf import settings from requests import HTTPError from rest_framework.exceptions import APIException, NotFound, PermissionDenied, ValidationError -from urllib3.exceptions import MaxRetryError, TimeoutError from sentry import features from sentry.constants import ObjectStatus @@ -27,7 +25,7 @@ get_coding_agent_prompt, get_project_seer_preferences, ) -from sentry.seer.models import SeerApiError +from sentry.seer.models import SeerApiError, SeerApiResponseValidationError from sentry.seer.signed_seer_api import make_signed_seer_api_request from sentry.shared_integrations.exceptions import ApiError @@ -212,7 +210,7 @@ def _launch_agents_for_repos( if preference_response and preference_response.preference: if preference_response.preference.automation_handoff: auto_create_pr = preference_response.preference.automation_handoff.auto_create_pr - except (SeerApiError, TimeoutError, MaxRetryError, pydantic.ValidationError): + except (SeerApiError, SeerApiResponseValidationError): logger.exception( "coding_agent.get_project_seer_preferences_error", extra={ diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 30b7ef46003a29..57bce33ec14340 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -4,6 +4,7 @@ from typing import TypedDict import orjson +import pydantic import requests from django.conf import settings from pydantic import BaseModel @@ -21,6 +22,7 @@ from sentry.seer.models import ( PreferenceResponse, SeerApiError, + SeerApiResponseValidationError, SeerPermissionError, SeerRepoDefinition, ) @@ -161,8 +163,11 @@ def get_project_seer_preferences(project_id: int): ) if response.status == 200: - result = orjson.loads(response.data) - return PreferenceResponse.validate(result) + try: + result = orjson.loads(response.data) + return PreferenceResponse.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) diff --git a/src/sentry/seer/models.py b/src/sentry/seer/models.py index 944f7a2d7f0553..99cf27ba32c14e 100644 --- a/src/sentry/seer/models.py +++ b/src/sentry/seer/models.py @@ -99,6 +99,14 @@ def __str__(self): return f"Seer API error: {self.message} (status: {self.status})" +class SeerApiResponseValidationError(Exception): + def __init__(self, message: str): + self.message = message + + def __str__(self): + return f"Seer API response validation error: {self.message}" + + class SeerPermissionError(Exception): def __init__(self, message: str): self.message = message From 96f572014d578d99d5a484dd3d21cfab61217374 Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Tue, 25 Nov 2025 03:58:44 +0700 Subject: [PATCH 09/10] fix tests --- .../endpoints/test_organization_coding_agents.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py index fa836bdbf57575..81b7445fd93d59 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py @@ -1279,6 +1279,7 @@ class OrganizationCodingAgentsPostInstructionTest(BaseOrganizationCodingAgentsTe @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") @patch( "sentry.integrations.services.integration.integration_service.get_organization_integration" ) @@ -1287,6 +1288,7 @@ def test_launch_with_custom_instruction( self, mock_get_integration, mock_get_org_integration, + mock_get_preferences, mock_get_prompt, mock_get_autofix_state, mock_get_providers, @@ -1294,6 +1296,7 @@ def test_launch_with_custom_instruction( """Test POST endpoint with custom instruction.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Test prompt with custom instruction" + mock_get_preferences.return_value = None mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -1325,6 +1328,7 @@ def test_launch_with_custom_instruction( @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") @patch( "sentry.integrations.services.integration.integration_service.get_organization_integration" ) @@ -1333,6 +1337,7 @@ def test_launch_with_blank_instruction( self, mock_get_integration, mock_get_org_integration, + mock_get_preferences, mock_get_prompt, mock_get_autofix_state, mock_get_providers, @@ -1340,6 +1345,7 @@ def test_launch_with_blank_instruction( """Test POST endpoint with blank instruction gets trimmed to empty string.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Test prompt without instruction" + mock_get_preferences.return_value = None mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -1367,6 +1373,7 @@ def test_launch_with_blank_instruction( @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") @patch( "sentry.integrations.services.integration.integration_service.get_organization_integration" ) @@ -1375,6 +1382,7 @@ def test_launch_with_empty_instruction( self, mock_get_integration, mock_get_org_integration, + mock_get_preferences, mock_get_prompt, mock_get_autofix_state, mock_get_providers, @@ -1382,6 +1390,7 @@ def test_launch_with_empty_instruction( """Test POST endpoint with empty instruction.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Test prompt" + mock_get_preferences.return_value = None mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -1409,6 +1418,7 @@ def test_launch_with_empty_instruction( @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") @patch( "sentry.integrations.services.integration.integration_service.get_organization_integration" ) @@ -1417,6 +1427,7 @@ def test_launch_with_max_length_instruction( self, mock_get_integration, mock_get_org_integration, + mock_get_preferences, mock_get_prompt, mock_get_autofix_state, mock_get_providers, @@ -1424,6 +1435,7 @@ def test_launch_with_max_length_instruction( """Test POST endpoint with max length instruction.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Test prompt with long instruction" + mock_get_preferences.return_value = None mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -1488,6 +1500,7 @@ def test_launch_with_too_long_instruction( @patch("sentry.seer.autofix.coding_agent.get_coding_agent_providers") @patch("sentry.seer.autofix.coding_agent.get_autofix_state") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") @patch( "sentry.integrations.services.integration.integration_service.get_organization_integration" ) @@ -1496,6 +1509,7 @@ def test_launch_with_instruction_and_root_cause_trigger( self, mock_get_integration, mock_get_org_integration, + mock_get_preferences, mock_get_prompt, mock_get_autofix_state, mock_get_providers, @@ -1503,6 +1517,7 @@ def test_launch_with_instruction_and_root_cause_trigger( """Test POST endpoint with custom instruction and root_cause trigger.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Root cause prompt with instruction" + mock_get_preferences.return_value = None mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration From bf51e52c66d5bcab73b35a82eacb8a2d3a59494b Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Tue, 25 Nov 2025 04:06:47 +0700 Subject: [PATCH 10/10] fix mocking issue --- .../test_organization_coding_agents.py | 66 ++++++++++++++----- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py index 81b7445fd93d59..6ed07bd353f644 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py @@ -20,7 +20,7 @@ CodingAgentState, CodingAgentStatus, ) -from sentry.seer.models import SeerRepoDefinition +from sentry.seer.models import PreferenceResponse, SeerRepoDefinition from sentry.testutils.cases import APITestCase @@ -567,7 +567,9 @@ def test_launches_coding_agent( # Mock coding agent providers to include github mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Test coding agent prompt" - mock_get_preferences.return_value = None + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -613,7 +615,9 @@ def test_launch_with_all_parameters( """Test POST endpoint with all launch parameters.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Test prompt for all parameters" - mock_get_preferences.return_value = None + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -686,7 +690,9 @@ def test_multi_repo_launch( """Test POST endpoint launches agents for multiple repositories.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Multi-repo test prompt" - mock_get_preferences.return_value = None + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -755,7 +761,9 @@ def test_repo_launch_error_continues_with_others( """Test POST endpoint continues with other repos when one repo fails.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Test prompt for repo launch error" - mock_get_preferences.return_value = None + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) # Create mock installation that fails for first repo failing_installation = MagicMock(spec=MockCodingAgentInstallation) @@ -845,7 +853,9 @@ def test_all_repos_fail_returns_failures( ): """Test POST endpoint returns failures when all repos fail to launch.""" mock_get_providers.return_value = ["github"] - mock_get_preferences.return_value = None + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) # Create mock installation that always fails failing_installation = MagicMock(spec=MockCodingAgentInstallation) @@ -930,7 +940,9 @@ def test_seer_storage_failure_continues( """Test POST endpoint continues when Seer storage fails.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Test prompt for seer storage failure" - mock_get_preferences.return_value = None + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) mock_store_to_seer.return_value = False # Simulate Seer storage failure mock_rpc_integration = self._create_mock_rpc_integration() @@ -973,7 +985,9 @@ def test_root_cause_trigger_source( """Test POST endpoint with root_cause trigger_source.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Root cause prompt" - mock_get_preferences.return_value = None + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -1020,7 +1034,9 @@ def test_root_cause_repos_extracted_and_deduped( """Root cause repos are extracted, de-duplicated, and used for launch.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Root cause prompt" - mock_get_preferences.return_value = None + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -1096,7 +1112,9 @@ def test_root_cause_without_relevant_repos_falls_back_to_request_repos( """If root cause has no relevant_repos, fallback to request repos path executes.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Root cause prompt" - mock_get_preferences.return_value = None + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -1166,7 +1184,9 @@ def test_solution_trigger_source( """Test POST endpoint with solution trigger_source.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Solution prompt" - mock_get_preferences.return_value = None + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -1247,7 +1267,9 @@ def test_prompt_not_available( """Test POST endpoint when prompt is not available.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = None # Prompt not available - mock_get_preferences.return_value = None + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -1296,7 +1318,9 @@ def test_launch_with_custom_instruction( """Test POST endpoint with custom instruction.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Test prompt with custom instruction" - mock_get_preferences.return_value = None + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -1345,7 +1369,9 @@ def test_launch_with_blank_instruction( """Test POST endpoint with blank instruction gets trimmed to empty string.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Test prompt without instruction" - mock_get_preferences.return_value = None + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -1390,7 +1416,9 @@ def test_launch_with_empty_instruction( """Test POST endpoint with empty instruction.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Test prompt" - mock_get_preferences.return_value = None + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -1435,7 +1463,9 @@ def test_launch_with_max_length_instruction( """Test POST endpoint with max length instruction.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Test prompt with long instruction" - mock_get_preferences.return_value = None + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration @@ -1517,7 +1547,9 @@ def test_launch_with_instruction_and_root_cause_trigger( """Test POST endpoint with custom instruction and root_cause trigger.""" mock_get_providers.return_value = ["github"] mock_get_prompt.return_value = "Root cause prompt with instruction" - mock_get_preferences.return_value = None + mock_get_preferences.return_value = PreferenceResponse( + preference=None, code_mapping_repos=[] + ) mock_rpc_integration = self._create_mock_rpc_integration() mock_get_org_integration.return_value = self.rpc_org_integration