Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions src/sentry/integrations/coding_agent/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
116 changes: 110 additions & 6 deletions src/sentry/integrations/cursor/webhooks/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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")

Expand All @@ -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")
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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
2 changes: 1 addition & 1 deletion src/sentry/seer/autofix/coding_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 25 additions & 2 deletions src/sentry/seer/autofix/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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")
5 changes: 4 additions & 1 deletion tests/sentry/integrations/cursor/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
Loading
Loading