diff --git a/src/sentry/integrations/coding_agent/integration.py b/src/sentry/integrations/coding_agent/integration.py index 820c65f42d525d..8f61f76e7be803 100644 --- a/src/sentry/integrations/coding_agent/integration.py +++ b/src/sentry/integrations/coding_agent/integration.py @@ -16,6 +16,7 @@ from sentry.integrations.coding_agent.models import CodingAgentLaunchRequest from sentry.seer.autofix.utils import CodingAgentState from sentry.utils.http import absolute_uri +from sentry.utils.urls import add_params_to_url # Default metadata for coding agent integrations DEFAULT_CODING_AGENT_METADATA = IntegrationMetadata( @@ -56,16 +57,28 @@ def get_client(self) -> CodingAgentClient: """Get API client for the coding agent.""" pass - def get_webhook_url(self) -> str: - """Generate webhook URL for this integration.""" - return absolute_uri( + def get_webhook_url(self, *, run_id: int | None = None) -> str: + """Generate webhook URL for this integration. + + Args: + run_id: Optional Autofix run id to include as a query parameter so that + webhook callbacks can be linked back to their originating run. + """ + url = absolute_uri( f"/extensions/{self.model.provider}/organizations/{self.organization_id}/webhook/", url_prefix=generate_region_url(), ) - def launch(self, request: CodingAgentLaunchRequest) -> CodingAgentState: + if run_id is not None: + url = add_params_to_url(url, {"run_id": str(run_id)}) + + return url + + def launch( + self, request: CodingAgentLaunchRequest, run_id: int | None = None + ) -> CodingAgentState: """Launch coding agent with webhook callback URL.""" - webhook_url = self.get_webhook_url() + webhook_url = self.get_webhook_url(run_id=run_id) client = self.get_client() return client.launch( diff --git a/src/sentry/integrations/cursor/webhooks/handler.py b/src/sentry/integrations/cursor/webhooks/handler.py index 5b57c817432548..8ad446220327b6 100644 --- a/src/sentry/integrations/cursor/webhooks/handler.py +++ b/src/sentry/integrations/cursor/webhooks/handler.py @@ -24,8 +24,10 @@ from sentry.integrations.services.integration import integration_service from sentry.models.organization import Organization from sentry.seer.autofix.utils import ( + AutofixState, CodingAgentResult, CodingAgentStatus, + get_autofix_state, update_coding_agent_state, ) from sentry.seer.models import SeerApiError @@ -65,7 +67,9 @@ def post(self, request: Request, organization_id: int) -> Response: logger.warning("cursor_webhook.invalid_signature") raise PermissionDenied("Invalid signature") - self._process_webhook(payload) + run_id = self._get_run_id_from_request(request) + + self._process_webhook(payload, organization, run_id) logger.info("cursor_webhook.success", extra={"event_type": event_type}) return self.respond(status=204) @@ -133,7 +137,33 @@ def _validate_signature(self, request: Request, raw_body: bytes, organization_id return is_valid - def _process_webhook(self, payload: dict[str, Any]) -> None: + def _get_run_id_from_request(self, request: Request) -> int | None: + """Extract run_id query parameter if present.""" + run_id_raw = request.query_params.get("run_id") + if run_id_raw is None: + return None + + try: + run_id = int(run_id_raw) + except (TypeError, ValueError): + logger.warning( + "cursor_webhook.invalid_run_id_param", + extra={"run_id": run_id_raw}, + ) + return None + + if run_id <= 0: + logger.warning( + "cursor_webhook.invalid_run_id_param", + extra={"run_id": run_id}, + ) + return None + + return run_id + + def _process_webhook( + self, payload: dict[str, Any], organization: Organization, run_id: int | None + ) -> None: """Process webhook payload based on event type.""" event_type = payload.get("event", "unknown") @@ -143,13 +173,15 @@ def _process_webhook(self, payload: dict[str, Any]) -> None: } handler = handlers.get(event_type, self._handle_unknown_event) - handler(payload) + handler(payload, organization=organization, run_id=run_id) - def _handle_unknown_event(self, payload: dict[str, Any]) -> None: + def _handle_unknown_event(self, payload: dict[str, Any], **_: Any) -> None: """Handle unknown event types.""" logger.error("cursor_webhook.unknown_event", extra=payload) - def _handle_status_change(self, payload: dict[str, Any]) -> None: + def _handle_status_change( + self, payload: dict[str, Any], organization: Organization, run_id: int | None + ) -> None: """Handle status change events.""" agent_id = payload.get("id") cursor_status = payload.get("status") @@ -166,6 +198,17 @@ def _handle_status_change(self, payload: dict[str, Any]) -> None: ) return + if run_id is None: + logger.info( + "cursor_webhook.run_id_missing", + extra={"agent_id": agent_id}, + ) + else: + if not self._is_agent_registered( + agent_id=agent_id, run_id=run_id, organization=organization + ): + return + status = CodingAgentStatus.from_cursor_status(cursor_status) if not status: logger.error( @@ -229,14 +272,53 @@ def _handle_status_change(self, payload: dict[str, Any]) -> None: status=status, agent_url=agent_url, result=result, + run_id=run_id, ) + def _fetch_autofix_state( + self, *, organization: Organization, run_id: int + ) -> AutofixState | None: + try: + return get_autofix_state(run_id=run_id, organization_id=organization.id) + except Exception: + logger.exception( + "cursor_webhook.autofix_state_fetch_failed", + extra={"organization_id": organization.id, "run_id": run_id}, + ) + return None + + def _is_agent_registered( + self, *, agent_id: str, run_id: int, organization: Organization + ) -> bool: + state = self._fetch_autofix_state(organization=organization, run_id=run_id) + if state is None: + logger.warning( + "cursor_webhook.autofix_state_unavailable", + extra={"organization_id": organization.id, "run_id": run_id}, + ) + return False + + coding_agents = state.coding_agents or {} + if agent_id not in coding_agents: + logger.warning( + "cursor_webhook.agent_not_registered_for_run", + extra={ + "organization_id": organization.id, + "run_id": run_id, + "agent_id": agent_id, + }, + ) + return False + + return True + def _update_coding_agent_status( self, agent_id: str, status: CodingAgentStatus, agent_url: str | None = None, result: CodingAgentResult | None = None, + run_id: int | None = None, ): try: update_coding_agent_state( @@ -251,13 +333,35 @@ def _update_coding_agent_status( "agent_id": agent_id, "status": status.value, "has_result": result is not None, + "run_id": run_id, }, ) - except SeerApiError: + except SeerApiError as exc: + if self._is_missing_run_error(exc): + logger.info( + "cursor_webhook.agent_not_found_in_seer", + extra={ + "agent_id": agent_id, + "status": status.value, + "run_id": run_id, + }, + ) + return + logger.exception( "cursor_webhook.seer_update_error", extra={ "agent_id": agent_id, "status": status.value, + "run_id": run_id, }, ) + + def _is_missing_run_error(self, error: SeerApiError) -> bool: + try: + payload = orjson.loads(error.message) + except orjson.JSONDecodeError: + return False + + detail = payload.get("detail") + return isinstance(detail, str) and "No run_id found" in detail diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index 628850f7f75107..1e46d28a0f4774 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -269,7 +269,7 @@ def _launch_agents_for_repos( ) try: - coding_agent_state = installation.launch(launch_request) + coding_agent_state = installation.launch(request=launch_request, run_id=run_id) except (HTTPError, ApiError) as e: logger.exception( "coding_agent.repo_launch_error", diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index f11e2e2d00249b..0e33de9301de43 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -446,10 +446,12 @@ def update_coding_agent_state( status: CodingAgentStatus, agent_url: str | None = None, result: CodingAgentResult | None = None, -) -> None: +) -> bool: """Send coding agent state update to Seer. - Raises SeerApiError for non-2xx responses. + Returns True if the update was applied. Returns False when the agent_id + cannot be mapped to a Seer autofix run. Raises SeerApiError for other + non-2xx responses. """ path = "/v1/automation/autofix/coding-agent/state/update" @@ -474,4 +476,25 @@ def update_coding_agent_state( ) if response.status >= 400: + if response.status == 404 and _is_missing_agent_mapping(response.data): + logger.info( + "seer.autofix.coding_agent_state_not_registered", + extra={"agent_id": agent_id}, + ) + return False raise SeerApiError(response.data.decode("utf-8"), response.status) + + return True + + +def _is_missing_agent_mapping(response_data: bytes) -> bool: + try: + body = orjson.loads(response_data) + except orjson.JSONDecodeError: + return False + + detail = body.get("detail") + if not isinstance(detail, str): + return False + + return detail.startswith("No run_id found for agent_id") diff --git a/tests/sentry/integrations/cursor/test_integration.py b/tests/sentry/integrations/cursor/test_integration.py index f717fb0bd164f9..d0ebb31f8e6809 100644 --- a/tests/sentry/integrations/cursor/test_integration.py +++ b/tests/sentry/integrations/cursor/test_integration.py @@ -164,7 +164,8 @@ def test_launch(self, mock_post): branch_name="fix-bug", ) - result = cast(CursorAgentIntegration, installation).launch(request=request) + run_id = 123 + result = cast(CursorAgentIntegration, installation).launch(request=request, run_id=run_id) assert result.id == "test_session_123" assert result.status == CodingAgentStatus.RUNNING @@ -174,6 +175,8 @@ def test_launch(self, mock_post): mock_post.assert_called_once() call_args = mock_post.call_args assert call_args[0][0] == "/v0/agents" + payload = call_args[1]["data"] + assert payload["webhook"]["url"].endswith(f"?run_id={run_id}") def test_update_organization_config_persists_api_key_and_clears_org_config(self): integration = self.create_integration( diff --git a/tests/sentry/integrations/cursor/test_webhook.py b/tests/sentry/integrations/cursor/test_webhook.py index 724cf46a4025dc..92d253d608d65b 100644 --- a/tests/sentry/integrations/cursor/test_webhook.py +++ b/tests/sentry/integrations/cursor/test_webhook.py @@ -30,22 +30,47 @@ def setUp(self): }, ) self.installation = self.integration.get_installation(organization_id=self.organization.id) + self.default_run_id = 101 - def _url(self) -> str: - return reverse( + patcher = patch( + "sentry.integrations.cursor.webhooks.handler.CursorWebhookEndpoint._is_agent_registered", + return_value=True, + ) + self.mock_is_agent_registered = patcher.start() + self.addCleanup(patcher.stop) + + def _url(self, include_run_id: bool = True, run_id: int | None = None) -> str: + base = reverse( "sentry-extensions-cursor-webhook", kwargs={"organization_id": self.organization.id}, ) + if not include_run_id: + return base + + rid = run_id if run_id is not None else self.default_run_id + return f"{base}?run_id={rid}" def _signed_headers(self, body: bytes, secret: str | None = None) -> dict[str, str]: used_secret = secret or self.integration.metadata["webhook_secret"] signature = hmac.new(used_secret.encode("utf-8"), body, hashlib.sha256).hexdigest() return {"HTTP_X_WEBHOOK_SIGNATURE": f"sha256={signature}"} - def _post_with_headers(self, body: bytes, headers: dict[str, str]): + def _post_with_headers( + self, + body: bytes, + headers: dict[str, str], + *, + include_run_id: bool = True, + run_id: int | None = None, + ): # mypy: The DRF APIClient stubs can misinterpret **extra headers as a positional arg. client: Any = self.client - return client.post(self._url(), data=body, content_type="application/json", **headers) + return client.post( + self._url(include_run_id=include_run_id, run_id=run_id), + data=body, + content_type="application/json", + **headers, + ) def _build_status_payload( self, @@ -164,6 +189,34 @@ def test_unknown_status_logs_and_defaults_to_failed(self, mock_update_state): args, kwargs = mock_update_state.call_args assert kwargs["status"].name == "FAILED" + @patch("sentry.integrations.cursor.webhooks.handler.update_coding_agent_state") + def test_missing_run_id_skips_registration_check(self, mock_update_state): + self.mock_is_agent_registered.reset_mock() + payload = self._build_status_payload(status="FINISHED") + body = orjson.dumps(payload) + headers = self._signed_headers(body) + + with Feature({"organizations:seer-coding-agent-integrations": True}): + response = self._post_with_headers(body, headers, include_run_id=False) + + assert response.status_code == 204 + assert mock_update_state.call_count == 1 + self.mock_is_agent_registered.assert_not_called() + + @patch("sentry.integrations.cursor.webhooks.handler.update_coding_agent_state") + def test_agent_not_registered_for_run(self, mock_update_state): + self.mock_is_agent_registered.return_value = False + payload = self._build_status_payload(status="FINISHED") + body = orjson.dumps(payload) + headers = self._signed_headers(body) + + with Feature({"organizations:seer-coding-agent-integrations": True}): + response = self._post_with_headers(body, headers) + + assert response.status_code == 204 + mock_update_state.assert_not_called() + self.mock_is_agent_registered.return_value = True + def test_missing_agent_id_or_status(self): # Missing id body = orjson.dumps(self._build_status_payload(id=None)) @@ -254,3 +307,19 @@ def test_seer_api_error_is_caught(self, mock_update_state): response = self._post_with_headers(body, headers) assert response.status_code == 204 # Even with exception, endpoint must not raise + + @patch("sentry.integrations.cursor.webhooks.handler.update_coding_agent_state") + def test_seer_missing_run_id_error_is_ignored(self, mock_update_state): + from sentry.seer.models import SeerApiError + + mock_update_state.side_effect = SeerApiError( + '{"detail":"No run_id found for agent"}', status=404 + ) + payload = self._build_status_payload(status="FINISHED") + body = orjson.dumps(payload) + headers = self._signed_headers(body) + + with Feature({"organizations:seer-coding-agent-integrations": True}): + response = self._post_with_headers(body, headers) + + assert response.status_code == 204 diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index fb87fd08c0f2c9..42fc29dba31b3b 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -8,10 +8,12 @@ from sentry.seer.autofix.utils import ( AutofixState, AutofixTriggerSource, + CodingAgentResult, CodingAgentStatus, get_autofix_prompt, get_coding_agent_prompt, is_issue_eligible_for_seer_automation, + update_coding_agent_state, ) from sentry.seer.models import SeerApiError from sentry.testutils.cases import TestCase @@ -140,6 +142,74 @@ def test_get_coding_agent_prompt_root_cause_trigger(self, mock_get_autofix_promp mock_get_autofix_prompt.assert_called_once_with(12345, True, False) +class TestUpdateCodingAgentState(TestCase): + def setUp(self): + super().setUp() + self.result = CodingAgentResult( + description="done", + repo_provider="github", + repo_full_name="getsentry/sentry", + pr_url="https://github.com/getsentry/sentry/pull/1", + ) + + @patch("sentry.seer.autofix.utils.make_signed_seer_api_request") + def test_successful_update_returns_true(self, mock_make_request): + mock_response = Mock() + mock_response.status = 200 + mock_response.data = b"{}" + mock_make_request.return_value = mock_response + + applied = update_coding_agent_state( + agent_id="agent-1", + status=CodingAgentStatus.COMPLETED, + agent_url="https://cursor.com/agents/agent-1", + result=self.result, + ) + + assert applied is True + call = mock_make_request.call_args + assert call.args[1] == "/v1/automation/autofix/coding-agent/state/update" + payload = orjson.loads(call.kwargs["body"]) + assert payload["agent_id"] == "agent-1" + assert payload["updates"]["status"] == "completed" + + @patch("sentry.seer.autofix.utils.logger") + @patch("sentry.seer.autofix.utils.make_signed_seer_api_request") + def test_missing_agent_is_ignored(self, mock_make_request, mock_logger): + mock_response = Mock() + mock_response.status = 404 + mock_response.data = orjson.dumps({"detail": "No run_id found for agent_id agent-404"}) + mock_make_request.return_value = mock_response + + applied = update_coding_agent_state( + agent_id="agent-404", + status=CodingAgentStatus.COMPLETED, + agent_url=None, + result=self.result, + ) + + assert applied is False + mock_logger.info.assert_called_once_with( + "seer.autofix.coding_agent_state_not_registered", + extra={"agent_id": "agent-404"}, + ) + + @patch("sentry.seer.autofix.utils.make_signed_seer_api_request") + def test_other_errors_raise(self, mock_make_request): + mock_response = Mock() + mock_response.status = 404 + mock_response.data = orjson.dumps({"detail": "Some other issue"}) + mock_make_request.return_value = mock_response + + with pytest.raises(SeerApiError): + update_coding_agent_state( + agent_id="agent-unknown", + status=CodingAgentStatus.COMPLETED, + agent_url=None, + result=self.result, + ) + + class TestAutofixStateParsing(TestCase): def test_autofix_state_validate_parses_nested_structures(self): state_data = {