diff --git a/src/sentry/integrations/cursor/client.py b/src/sentry/integrations/cursor/client.py index dca1dc0690d9d1..f280b38a0a702d 100644 --- a/src/sentry/integrations/cursor/client.py +++ b/src/sentry/integrations/cursor/client.py @@ -11,6 +11,7 @@ CursorAgentLaunchRequestWebhook, CursorAgentLaunchResponse, CursorAgentSource, + CursorApiKeyMetadata, ) from sentry.seer.autofix.utils import CodingAgentProviderType, CodingAgentState, CodingAgentStatus @@ -30,6 +31,24 @@ def __init__(self, api_key: str, webhook_secret: str): def _get_auth_headers(self) -> dict[str, str]: return {"Authorization": f"Bearer {self.api_key}"} + def get_api_key_metadata(self) -> CursorApiKeyMetadata: + """Fetch metadata about the API key from Cursor's /v0/me endpoint.""" + logger.info( + "coding_agent.cursor.get_api_key_metadata", + extra={"agent_type": self.__class__.__name__}, + ) + + api_response = self.get( + "/v0/me", + headers={ + "content-type": "application/json;charset=utf-8", + **self._get_auth_headers(), + }, + timeout=30, + ) + + return CursorApiKeyMetadata.validate(api_response.json) + def launch(self, webhook_url: str, request: CodingAgentLaunchRequest) -> CodingAgentState: """Launch coding agent with webhook callback.""" payload = CursorAgentLaunchRequestBody( diff --git a/src/sentry/integrations/cursor/integration.py b/src/sentry/integrations/cursor/integration.py index d20ecd517df5e8..9e9b8c0d98962d 100644 --- a/src/sentry/integrations/cursor/integration.py +++ b/src/sentry/integrations/cursor/integration.py @@ -8,7 +8,8 @@ from django.http.request import HttpRequest from django.http.response import HttpResponseBase from django.utils.translation import gettext_lazy as _ -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError +from requests import HTTPError from sentry.integrations.base import ( FeatureDescription, @@ -26,7 +27,7 @@ from sentry.integrations.services.integration import integration_service from sentry.integrations.services.integration.model import RpcIntegration from sentry.models.apitoken import generate_token -from sentry.shared_integrations.exceptions import IntegrationConfigurationError +from sentry.shared_integrations.exceptions import ApiError, IntegrationConfigurationError DESCRIPTION = "Connect your Sentry organization with Cursor Cloud Agents." @@ -42,6 +43,8 @@ class CursorIntegrationMetadata(BaseModel): api_key: str webhook_secret: str domain_name: Literal["cursor.sh"] = "cursor.sh" + api_key_name: str | None = None + user_email: str | None = None metadata = IntegrationMetadata( @@ -106,11 +109,36 @@ def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: raise IntegrationConfigurationError("Missing configuration data") webhook_secret = generate_token() + api_key = config["api_key"] + + api_key_name = None + user_email = None + try: + client = CursorAgentClient(api_key=api_key, webhook_secret=webhook_secret) + cursor_metadata = client.get_api_key_metadata() + api_key_name = cursor_metadata.apiKeyName + user_email = cursor_metadata.userEmail + except (HTTPError, ApiError): + self.get_logger().exception( + "cursor.build_integration.metadata_fetch_failed", + ) + except ValidationError: + self.get_logger().exception( + "cursor.build_integration.metadata_validation_failed", + ) + + integration_name = ( + f"Cursor Cloud Agent - {user_email}/{api_key_name}" + if user_email and api_key_name + else "Cursor Cloud Agent" + ) metadata = CursorIntegrationMetadata( domain_name="cursor.sh", - api_key=config["api_key"], + api_key=api_key, webhook_secret=webhook_secret, + api_key_name=api_key_name, + user_email=user_email, ) return { @@ -118,7 +146,7 @@ def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: # Why UUIDs? We use UUIDs here for each integration installation because we don't know how many times this USER-LEVEL API key will be used, or if the same org can have multiple cursor agents (in the near future) # or if the same user can have multiple installations across multiple orgs. So just a UUID per installation is the best approach. Re-configuring an existing installation will still maintain this external id "external_id": uuid.uuid4().hex, - "name": "Cursor Agent", + "name": integration_name, "metadata": metadata.dict(), } @@ -170,6 +198,18 @@ def get_client(self): webhook_secret=self.webhook_secret, ) + def get_dynamic_display_information(self) -> Mapping[str, Any] | None: + """Return metadata to display in the configurations list.""" + metadata = CursorIntegrationMetadata.parse_obj(self.model.metadata or {}) + + display_info = {} + if metadata.api_key_name: + display_info["api_key_name"] = metadata.api_key_name + if metadata.user_email: + display_info["user_email"] = metadata.user_email + + return display_info if display_info else None + @property def webhook_secret(self) -> str: return CursorIntegrationMetadata.parse_obj(self.model.metadata).webhook_secret diff --git a/src/sentry/integrations/cursor/models.py b/src/sentry/integrations/cursor/models.py index b2f8e39739a208..629e05c7bdc62c 100644 --- a/src/sentry/integrations/cursor/models.py +++ b/src/sentry/integrations/cursor/models.py @@ -3,6 +3,12 @@ from pydantic import BaseModel +class CursorApiKeyMetadata(BaseModel): + apiKeyName: str + createdAt: str + userEmail: str + + class CursorAgentLaunchRequestPrompt(BaseModel): text: str images: list[dict] = [] diff --git a/tests/sentry/integrations/cursor/test_integration.py b/tests/sentry/integrations/cursor/test_integration.py index f717fb0bd164f9..534bf24969cd50 100644 --- a/tests/sentry/integrations/cursor/test_integration.py +++ b/tests/sentry/integrations/cursor/test_integration.py @@ -11,7 +11,7 @@ CursorAgentIntegration, CursorAgentIntegrationProvider, ) -from sentry.shared_integrations.exceptions import IntegrationConfigurationError +from sentry.shared_integrations.exceptions import ApiError, IntegrationConfigurationError from sentry.testutils.cases import IntegrationTestCase from sentry.testutils.silo import assume_test_silo_mode_of @@ -38,6 +38,63 @@ def test_build_integration_stores_metadata(provider): assert metadata["webhook_secret"] == "hook-secret" +def test_build_integration_fetches_and_stores_api_key_metadata(provider): + """Test that build_integration fetches metadata from /v0/me and stores it""" + from sentry.integrations.cursor.models import CursorApiKeyMetadata + + fake_uuid = UUID("22222222-3333-4444-5555-666666666666") + mock_metadata = CursorApiKeyMetadata( + apiKeyName="Production API Key", + createdAt="2024-01-15T10:30:00Z", + userEmail="developer@example.com", + ) + + with ( + patch("sentry.integrations.cursor.integration.uuid.uuid4", return_value=fake_uuid), + patch("sentry.integrations.cursor.integration.generate_token", return_value="hook-secret"), + patch( + "sentry.integrations.cursor.client.CursorAgentClient.get_api_key_metadata" + ) as mock_get_metadata, + ): + mock_get_metadata.return_value = mock_metadata + integration_data = provider.build_integration(state={"config": {"api_key": "cursor-api"}}) + + # Verify metadata was fetched + mock_get_metadata.assert_called_once() + + # Verify metadata is stored + metadata = integration_data["metadata"] + assert metadata["api_key_name"] == "Production API Key" + assert metadata["user_email"] == "developer@example.com" + + # Verify integration name includes API key name + assert ( + integration_data["name"] == "Cursor Cloud Agent - developer@example.com/Production API Key" + ) + + +def test_build_integration_fallback_on_metadata_fetch_failure(provider): + """Test that build_integration falls back gracefully if metadata fetch fails""" + fake_uuid = UUID("33333333-4444-5555-6666-777777777777") + + with ( + patch("sentry.integrations.cursor.integration.uuid.uuid4", return_value=fake_uuid), + patch("sentry.integrations.cursor.integration.generate_token", return_value="hook-secret"), + patch( + "sentry.integrations.cursor.client.CursorAgentClient.get_api_key_metadata" + ) as mock_get_metadata, + ): + # Simulate API call failure + mock_get_metadata.side_effect = ApiError("API Error", 500) + integration_data = provider.build_integration(state={"config": {"api_key": "cursor-api"}}) + + # Verify integration was still created with fallback name + assert integration_data["name"] == "Cursor Cloud Agent" + metadata = integration_data["metadata"] + assert metadata["api_key_name"] is None + assert metadata["user_email"] is None + + def test_build_integration_stores_api_key_and_webhook_secret(provider): """Test that build_integration stores both API key and webhook secret""" integration_data = provider.build_integration(state={"config": {"api_key": "new-api"}}) @@ -266,7 +323,7 @@ def test_build_integration_creates_unique_installations(self): # All should have the same basic structure for integration_dict in [integration_dict_1, integration_dict_2, integration_dict_3]: - assert integration_dict["name"] == "Cursor Agent" + assert integration_dict["name"] == "Cursor Cloud Agent" assert "external_id" in integration_dict assert "metadata" in integration_dict assert integration_dict["metadata"]["domain_name"] == "cursor.sh" @@ -280,3 +337,53 @@ def test_build_integration_creates_unique_installations(self): webhook_secrets = {webhook_secret_1, webhook_secret_2, webhook_secret_3} assert len(webhook_secrets) == 3, "Each integration should have a unique webhook secret" + + def test_get_dynamic_display_information(self): + """Test that get_dynamic_display_information returns metadata""" + integration = self.create_integration( + organization=self.organization, + provider="cursor", + name="Cursor Agent - Production Key", + external_id="cursor", + metadata={ + "api_key": "test_api_key", + "webhook_secret": "test_secret", + "domain_name": "cursor.sh", + "api_key_name": "Production Key", + "user_email": "dev@example.com", + }, + ) + + installation = cast( + CursorAgentIntegration, + integration.get_installation(organization_id=self.organization.id), + ) + + display_info = installation.get_dynamic_display_information() + + assert display_info is not None + assert display_info["api_key_name"] == "Production Key" + assert display_info["user_email"] == "dev@example.com" + + def test_get_dynamic_display_information_returns_none_when_no_metadata(self): + """Test that get_dynamic_display_information returns None when metadata is missing""" + integration = self.create_integration( + organization=self.organization, + provider="cursor", + name="Cursor Agent", + external_id="cursor", + metadata={ + "api_key": "test_api_key", + "webhook_secret": "test_secret", + "domain_name": "cursor.sh", + }, + ) + + installation = cast( + CursorAgentIntegration, + integration.get_installation(organization_id=self.organization.id), + ) + + display_info = installation.get_dynamic_display_information() + + assert display_info is None