diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index ad9e9fd3cca7db..5d61bdb065e38e 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -12,6 +12,24 @@ from rest_framework.exceptions import APIException, NotFound, PermissionDenied, ValidationError from sentry import features + + +class IntegrationNotFound(NotFound): + pass + + +class OrganizationNotFound(NotFound): + pass + + +class AutofixStateNotFound(NotFound): + pass + + +class StateReposNotFound(NotFound): + pass + + from sentry.constants import ENABLE_SEER_CODING_DEFAULT, ObjectStatus from sentry.integrations.claude_code.integration import ( ClaudeCodeIntegrationMetadata, @@ -118,7 +136,7 @@ def _validate_and_get_integration(organization, integration_id: int): ) if not org_integration or org_integration.status != ObjectStatus.ACTIVE: - raise NotFound("Integration not found") + raise IntegrationNotFound("Integration not found") integration = integration_service.get_integration( organization_integration_id=org_integration.id, @@ -126,7 +144,7 @@ def _validate_and_get_integration(organization, integration_id: int): ) if not integration: - raise NotFound("Integration not found") + raise IntegrationNotFound("Integration not found") # Verify it's a coding agent integration if integration.provider not in get_coding_agent_providers(): @@ -252,7 +270,7 @@ def _launch_agents_for_repos( repos_to_launch = validated_repos or autofix_state_repos if not repos_to_launch: - raise NotFound( + raise StateReposNotFound( "There are no repos in the Seer state to launch coding agents with, make sure you have repos connected to Seer and rerun this Issue Fix." ) @@ -426,7 +444,7 @@ def launch_coding_agents_for_run( try: organization = Organization.objects.get(id=organization_id) except Organization.DoesNotExist: - raise NotFound("Organization not found") + raise OrganizationNotFound("Organization not found") if not organization.get_option("sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT): raise PermissionDenied("Code generation is disabled for this organization") @@ -457,7 +475,7 @@ def launch_coding_agents_for_run( autofix_state = _get_autofix_state(run_id, organization) if autofix_state is None: - raise NotFound("Autofix state not found") + raise AutofixStateNotFound("Autofix state not found") logger.info( "coding_agent.launch_request", diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index 84b6df842f4e07..0f86910bb20719 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -8,14 +8,22 @@ from sentry import features from sentry.models.group import Group from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.seer.autofix.autofix_agent import ( AutofixStep, trigger_autofix_explorer, trigger_coding_agent_handoff, trigger_push_changes, ) +from sentry.seer.autofix.coding_agent import IntegrationNotFound from sentry.seer.autofix.constants import AutofixReferrer -from sentry.seer.autofix.utils import AutofixStoppingPoint, get_project_seer_preferences +from sentry.seer.autofix.utils import ( + AutofixStoppingPoint, + get_project_seer_preferences, + resolve_repository_ids, + set_project_seer_preference, + write_preference_to_sentry_db, +) from sentry.seer.entrypoints.operator import SeerAutofixOperator, process_autofix_updates from sentry.seer.explorer.client_models import Artifact from sentry.seer.explorer.client_utils import fetch_run_status @@ -25,6 +33,7 @@ SeerApiResponseValidationError, SeerAutomationHandoffConfiguration, ) +from sentry.seer.models.seer_api_models import SeerProjectPreference from sentry.seer.supergroups.embeddings import trigger_supergroups_embedding from sentry.sentry_apps.metrics import SentryAppEventType from sentry.sentry_apps.tasks.sentry_apps import broadcast_webhooks_for_organization @@ -472,6 +481,35 @@ def _get_handoff_config_if_applicable( return handoff_config + @classmethod + def _clear_handoff_preference( + cls, project: Project, run_id: int, organization: Organization + ) -> None: + """Clear automation_handoff from project preferences after integration is not found.""" + try: + preference_response = get_project_seer_preferences(project.id) + if preference_response and preference_response.preference: + updated_preference = preference_response.preference.copy( + update={"automation_handoff": None} + ) + set_project_seer_preference(updated_preference) + + if features.has("organizations:seer-project-settings-dual-write", organization): + try: + validated_pref = SeerProjectPreference.validate(updated_preference) + resolved_pref = resolve_repository_ids(organization.id, [validated_pref]) + write_preference_to_sentry_db(project, resolved_pref[0]) + except Exception: + logger.exception( + "seer.write_preferences.failed", + extra={"project_id": project.id, "organization_id": organization.id}, + ) + except (SeerApiError, SeerApiResponseValidationError): + logger.exception( + "autofix.on_completion_hook.clear_handoff_preference_failed", + extra={"run_id": run_id, "organization_id": organization.id}, + ) + @classmethod def _trigger_coding_agent_handoff( cls, @@ -508,6 +546,16 @@ def _trigger_coding_agent_handoff( "failures": len(result.get("failures", [])), }, ) + except IntegrationNotFound: + logger.exception( + "autofix.on_completion_hook.coding_agent_handoff_integration_not_found", + extra={ + "run_id": run_id, + "organization_id": organization.id, + "integration_id": handoff_config.integration_id, + }, + ) + cls._clear_handoff_preference(group.project, run_id, organization) except Exception: logger.exception( "autofix.on_completion_hook.coding_agent_handoff_failed", diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index f6c034d673e24b..cdabc2867d0bef 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -36,6 +36,7 @@ from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey, AttributeValue, StrArray from sentry_protos.snuba.v1.trace_item_filter_pb2 import ComparisonFilter, TraceItemFilter +from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.authentication import AuthenticationSiloLimit, StandardAuthentication @@ -73,8 +74,19 @@ get_attribute_values_with_substring, ) from sentry.seer.autofix.autofix_tools import get_error_event_details, get_profile_details -from sentry.seer.autofix.coding_agent import launch_coding_agents_for_run -from sentry.seer.autofix.utils import AutofixTriggerSource +from sentry.seer.autofix.coding_agent import ( + AutofixStateNotFound, + IntegrationNotFound, + OrganizationNotFound, + StateReposNotFound, + launch_coding_agents_for_run, +) +from sentry.seer.autofix.utils import ( + AutofixTriggerSource, + get_project_seer_preferences, + resolve_repository_ids, + write_preference_to_sentry_db, +) from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS, SeerSCMProvider from sentry.seer.entrypoints.operator import SeerAutofixOperator, process_autofix_updates from sentry.seer.explorer.custom_tool_utils import call_custom_tool @@ -104,6 +116,7 @@ ) from sentry.seer.fetch_issues import by_error_type, by_function_name, by_text_query, utils from sentry.seer.issue_detection import create_issue_occurrence +from sentry.seer.models.seer_api_models import SeerProjectPreference from sentry.seer.utils import filter_repo_by_provider from sentry.sentry_apps.metrics import SentryAppEventType from sentry.sentry_apps.tasks.sentry_apps import broadcast_webhooks_for_organization @@ -565,6 +578,7 @@ def send_seer_webhook(*, event_name: str, organization_id: int, payload: dict) - def trigger_coding_agent_launch( *, organization_id: int, + project_id: int | None = None, integration_id: int, run_id: int, trigger_source: str = "solution", @@ -579,7 +593,7 @@ def trigger_coding_agent_launch( trigger_source: Either "root_cause" or "solution" (default: "solution") Returns: - dict: {"success": bool} + dict: {"success": bool, "error_code": str | None} """ try: launch_coding_agents_for_run( @@ -589,7 +603,45 @@ def trigger_coding_agent_launch( trigger_source=AutofixTriggerSource(trigger_source), ) return {"success": True} - except (NotFound, PermissionDenied, ValidationError, APIException): + except IntegrationNotFound: + logger.exception( + "coding_agent.rpc_launch_error", + extra={ + "organization_id": organization_id, + "integration_id": integration_id, + "run_id": run_id, + }, + ) + try: + project = Project.objects.get_from_cache(id=project_id) + organization = Organization.objects.get_from_cache(id=organization_id) + if features.has("organizations:seer-project-settings-dual-write", organization): + preference_response = get_project_seer_preferences(project.id) + if preference_response and preference_response.preference: + updated_preference = preference_response.preference.copy( + update={"automation_handoff": None} + ) + validated_pref = SeerProjectPreference.validate(updated_preference) + resolved_pref = resolve_repository_ids(organization.id, [validated_pref]) + write_preference_to_sentry_db(project, resolved_pref[0]) + except Exception: + logger.exception( + "coding_agent.clear_handoff_preference_failed", + extra={ + "project_id": project_id, + "organization_id": organization_id, + "run_id": run_id, + }, + ) + return {"success": False, "error_code": "integration_not_found"} + except ( + OrganizationNotFound, + AutofixStateNotFound, + StateReposNotFound, + PermissionDenied, + ValidationError, + APIException, + ): logger.exception( "coding_agent.rpc_launch_error", extra={ diff --git a/tests/sentry/seer/autofix/__init__.py b/tests/sentry/seer/autofix/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py index aa55052f2253d7..327669d82ec04c 100644 --- a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py @@ -608,6 +608,54 @@ def test_maybe_continue_pipeline_triggers_handoff_when_configured( mock_trigger_handoff.assert_called_once() + @patch("sentry.seer.autofix.on_completion_hook.set_project_seer_preference") + @patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences") + @patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff") + def test_trigger_coding_agent_handoff_clears_preference_on_not_found( + self, mock_trigger, mock_get_prefs, mock_set_pref + ): + """When IntegrationNotFound is raised, automation_handoff is cleared from preferences.""" + from sentry.seer.autofix.coding_agent import IntegrationNotFound + + mock_trigger.side_effect = IntegrationNotFound() + handoff_config = self._make_handoff_config() + mock_get_prefs.return_value = self._make_preference_response(handoff_config=handoff_config) + + AutofixOnCompletionHook._trigger_coding_agent_handoff( + organization=self.organization, + run_id=123, + group=self.group, + handoff_config=handoff_config, + ) + + mock_set_pref.assert_called_once() + updated = mock_set_pref.call_args.args[0] + assert updated.automation_handoff is None + + @patch("sentry.seer.autofix.on_completion_hook.set_project_seer_preference") + @patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences") + @patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff") + def test_trigger_coding_agent_handoff_not_found_seer_api_error_does_not_raise( + self, mock_trigger, mock_get_prefs, mock_set_pref + ): + """A SeerApiError during preference-clearing after IntegrationNotFound should not propagate.""" + from sentry.seer.autofix.coding_agent import IntegrationNotFound + from sentry.seer.models import SeerApiError + + mock_trigger.side_effect = IntegrationNotFound() + mock_get_prefs.side_effect = SeerApiError("seer unavailable", 503) + handoff_config = self._make_handoff_config() + + # Should not raise + AutofixOnCompletionHook._trigger_coding_agent_handoff( + organization=self.organization, + run_id=123, + group=self.group, + handoff_config=handoff_config, + ) + + mock_set_pref.assert_not_called() + @patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff") def test_trigger_coding_agent_handoff_calls_function(self, mock_trigger): """Test _trigger_coding_agent_handoff calls the trigger function correctly.""" diff --git a/tests/sentry/seer/autofix/test_on_completion_hook.py b/tests/sentry/seer/autofix/test_on_completion_hook.py new file mode 100644 index 00000000000000..7fc51b0eb87c91 --- /dev/null +++ b/tests/sentry/seer/autofix/test_on_completion_hook.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from sentry.seer.autofix.coding_agent import IntegrationNotFound +from sentry.seer.autofix.utils import CodingAgentProviderType +from sentry.seer.models.seer_api_models import SeerAutomationHandoffConfiguration +from sentry.testutils.cases import TestCase + + +class TestTriggerCodingAgentHandoff(TestCase): + def setUp(self) -> None: + super().setUp() + self.organization = self.create_organization() + self.project = self.create_project(organization=self.organization) + self.group = self.create_group(project=self.project) + + def _make_handoff_config(self, integration_id: int = 789) -> SeerAutomationHandoffConfiguration: + return SeerAutomationHandoffConfiguration( + handoff_point="root_cause", + target=CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, + integration_id=integration_id, + ) + + @patch("sentry.seer.autofix.on_completion_hook.set_project_seer_preference") + @patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences") + @patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff") + def test_not_found_clears_automation_handoff( + self, mock_trigger, mock_get_prefs, mock_set_pref + ) -> None: + from sentry.seer.autofix.on_completion_hook import AutofixOnCompletionHook + + mock_trigger.side_effect = IntegrationNotFound("Integration not found") + + mock_pref = MagicMock() + mock_pref.automation_handoff = self._make_handoff_config() + mock_pref.copy.return_value = mock_pref + mock_get_prefs.return_value = MagicMock(preference=mock_pref) + + handoff_config = self._make_handoff_config() + + AutofixOnCompletionHook._trigger_coding_agent_handoff( + organization=self.organization, + run_id=1, + group=self.group, + handoff_config=handoff_config, + ) + + mock_get_prefs.assert_called_once_with(self.group.project_id) + mock_pref.copy.assert_called_once_with(update={"automation_handoff": None}) + mock_set_pref.assert_called_once_with(mock_pref) + + @patch("sentry.seer.autofix.on_completion_hook.set_project_seer_preference") + @patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences") + @patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff") + def test_not_found_no_preference_response_does_not_call_set( + self, mock_trigger, mock_get_prefs, mock_set_pref + ) -> None: + from sentry.seer.autofix.on_completion_hook import AutofixOnCompletionHook + + mock_trigger.side_effect = IntegrationNotFound("Integration not found") + mock_get_prefs.return_value = None + + AutofixOnCompletionHook._trigger_coding_agent_handoff( + organization=self.organization, + run_id=1, + group=self.group, + handoff_config=self._make_handoff_config(), + ) + + mock_set_pref.assert_not_called() diff --git a/tests/sentry/seer/endpoints/test_seer_rpc.py b/tests/sentry/seer/endpoints/test_seer_rpc.py index f7b133775e5aa6..80e6e3e9c369a5 100644 --- a/tests/sentry/seer/endpoints/test_seer_rpc.py +++ b/tests/sentry/seer/endpoints/test_seer_rpc.py @@ -22,6 +22,7 @@ get_github_enterprise_integration_config, get_repo_installation_id, has_repo_code_mappings, + trigger_coding_agent_launch, validate_repo, ) from sentry.seer.explorer.tools import get_trace_item_attributes @@ -1479,3 +1480,129 @@ def test_get_repo_installation_id_integration_not_found(self) -> None: ) assert result == {"error": "integration_not_found"} + + +class TestTriggerCodingAgentLaunch: + @patch("sentry.seer.endpoints.seer_rpc.launch_coding_agents_for_run") + def test_not_found_returns_integration_not_found_error_code(self, mock_launch): + from sentry.seer.autofix.coding_agent import IntegrationNotFound + + mock_launch.side_effect = IntegrationNotFound() + + result = trigger_coding_agent_launch( + organization_id=1, + project_id=4, + integration_id=2, + run_id=3, + ) + + assert result == {"success": False, "error_code": "integration_not_found"} + + @patch("sentry.seer.endpoints.seer_rpc.launch_coding_agents_for_run") + def test_organization_not_found_does_not_return_integration_error_code(self, mock_launch): + from sentry.seer.autofix.coding_agent import OrganizationNotFound + + mock_launch.side_effect = OrganizationNotFound() + + result = trigger_coding_agent_launch( + organization_id=1, + project_id=4, + integration_id=2, + run_id=3, + ) + + assert result == {"success": False} + assert result.get("error_code") != "integration_not_found" + + @patch("sentry.seer.endpoints.seer_rpc.launch_coding_agents_for_run") + def test_autofix_state_not_found_does_not_return_integration_error_code(self, mock_launch): + from sentry.seer.autofix.coding_agent import AutofixStateNotFound + + mock_launch.side_effect = AutofixStateNotFound() + + result = trigger_coding_agent_launch( + organization_id=1, + project_id=4, + integration_id=2, + run_id=3, + ) + + assert result == {"success": False} + assert result.get("error_code") != "integration_not_found" + + +class TestTriggerCodingAgentLaunchClearsHandoff(APITestCase): + def _make_preference_response(self): + from sentry.seer.models.seer_api_models import ( + AutofixHandoffPoint, + SeerAutomationHandoffConfiguration, + SeerProjectPreference, + SeerRawPreferenceResponse, + ) + + return SeerRawPreferenceResponse( + preference=SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[], + automation_handoff=SeerAutomationHandoffConfiguration( + handoff_point=AutofixHandoffPoint.ROOT_CAUSE, + target="cursor_background_agent", + integration_id=42, + ), + ) + ) + + @patch("sentry.seer.endpoints.seer_rpc.get_project_seer_preferences") + @patch("sentry.seer.endpoints.seer_rpc.launch_coding_agents_for_run") + def test_integration_not_found_clears_handoff_project_options( + self, mock_launch, mock_get_prefs + ): + from sentry.seer.autofix.coding_agent import IntegrationNotFound + + mock_launch.side_effect = IntegrationNotFound() + mock_get_prefs.return_value = self._make_preference_response() + + self.project.update_option("sentry:seer_automation_handoff_point", "root_cause") + self.project.update_option( + "sentry:seer_automation_handoff_target", "cursor_background_agent" + ) + self.project.update_option("sentry:seer_automation_handoff_integration_id", 42) + self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) + + with self.feature("organizations:seer-project-settings-dual-write"): + result = trigger_coding_agent_launch( + organization_id=self.organization.id, + project_id=self.project.id, + integration_id=42, + run_id=99, + ) + + assert result == {"success": False, "error_code": "integration_not_found"} + assert self.project.get_option("sentry:seer_automation_handoff_point") is None + assert self.project.get_option("sentry:seer_automation_handoff_target") is None + assert self.project.get_option("sentry:seer_automation_handoff_integration_id") is None + assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False + + @patch("sentry.seer.endpoints.seer_rpc.get_project_seer_preferences") + @patch("sentry.seer.endpoints.seer_rpc.launch_coding_agents_for_run") + def test_integration_not_found_skips_clear_without_feature_flag( + self, mock_launch, mock_get_prefs + ): + from sentry.seer.autofix.coding_agent import IntegrationNotFound + + mock_launch.side_effect = IntegrationNotFound() + mock_get_prefs.return_value = self._make_preference_response() + + self.project.update_option("sentry:seer_automation_handoff_point", "root_cause") + + result = trigger_coding_agent_launch( + organization_id=self.organization.id, + project_id=self.project.id, + integration_id=42, + run_id=99, + ) + + assert result == {"success": False, "error_code": "integration_not_found"} + assert self.project.get_option("sentry:seer_automation_handoff_point") == "root_cause" + mock_get_prefs.assert_not_called() diff --git a/tests/sentry/seer/explorer/__init__.py b/tests/sentry/seer/explorer/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6