Skip to content
Merged
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
19 changes: 19 additions & 0 deletions src/sentry/integrations/cursor/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
CursorAgentLaunchRequestWebhook,
CursorAgentLaunchResponse,
CursorAgentSource,
CursorApiKeyMetadata,
)
from sentry.seer.autofix.utils import CodingAgentProviderType, CodingAgentState, CodingAgentStatus

Expand All @@ -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(
Expand Down
48 changes: 44 additions & 4 deletions src/sentry/integrations/cursor/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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."

Expand All @@ -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(
Expand Down Expand Up @@ -106,19 +109,44 @@ 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 {
# NOTE(jennmueng): We need to create a unique ID for each integration installation. Because of this, new installations will yield a unique external_id and integration.
# 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(),
}

Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/sentry/integrations/cursor/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down
111 changes: 109 additions & 2 deletions tests/sentry/integrations/cursor/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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="[email protected]",
)

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"] == "[email protected]"

# Verify integration name includes API key name
assert (
integration_data["name"] == "Cursor Cloud Agent - [email protected]/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"}})
Expand Down Expand Up @@ -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"
Expand All @@ -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": "[email protected]",
},
)

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"] == "[email protected]"

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
Loading