Skip to content

Commit e5271c5

Browse files
authored
feat(cursor-agent): include email and key name (#103989)
Because you can install more than 1 cursor agent integration for an org, it kinda gets confusing. Turns out there's an endpoint to get the email and key name of the owner of the key. We set that in the name to make it clear in uses.
1 parent ca73c7d commit e5271c5

File tree

4 files changed

+178
-6
lines changed

4 files changed

+178
-6
lines changed

src/sentry/integrations/cursor/client.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
CursorAgentLaunchRequestWebhook,
1212
CursorAgentLaunchResponse,
1313
CursorAgentSource,
14+
CursorApiKeyMetadata,
1415
)
1516
from sentry.seer.autofix.utils import CodingAgentProviderType, CodingAgentState, CodingAgentStatus
1617

@@ -30,6 +31,24 @@ def __init__(self, api_key: str, webhook_secret: str):
3031
def _get_auth_headers(self) -> dict[str, str]:
3132
return {"Authorization": f"Bearer {self.api_key}"}
3233

34+
def get_api_key_metadata(self) -> CursorApiKeyMetadata:
35+
"""Fetch metadata about the API key from Cursor's /v0/me endpoint."""
36+
logger.info(
37+
"coding_agent.cursor.get_api_key_metadata",
38+
extra={"agent_type": self.__class__.__name__},
39+
)
40+
41+
api_response = self.get(
42+
"/v0/me",
43+
headers={
44+
"content-type": "application/json;charset=utf-8",
45+
**self._get_auth_headers(),
46+
},
47+
timeout=30,
48+
)
49+
50+
return CursorApiKeyMetadata.validate(api_response.json)
51+
3352
def launch(self, webhook_url: str, request: CodingAgentLaunchRequest) -> CodingAgentState:
3453
"""Launch coding agent with webhook callback."""
3554
payload = CursorAgentLaunchRequestBody(

src/sentry/integrations/cursor/integration.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
from django.http.request import HttpRequest
99
from django.http.response import HttpResponseBase
1010
from django.utils.translation import gettext_lazy as _
11-
from pydantic import BaseModel
11+
from pydantic import BaseModel, ValidationError
12+
from requests import HTTPError
1213

1314
from sentry.integrations.base import (
1415
FeatureDescription,
@@ -26,7 +27,7 @@
2627
from sentry.integrations.services.integration import integration_service
2728
from sentry.integrations.services.integration.model import RpcIntegration
2829
from sentry.models.apitoken import generate_token
29-
from sentry.shared_integrations.exceptions import IntegrationConfigurationError
30+
from sentry.shared_integrations.exceptions import ApiError, IntegrationConfigurationError
3031

3132
DESCRIPTION = "Connect your Sentry organization with Cursor Cloud Agents."
3233

@@ -42,6 +43,8 @@ class CursorIntegrationMetadata(BaseModel):
4243
api_key: str
4344
webhook_secret: str
4445
domain_name: Literal["cursor.sh"] = "cursor.sh"
46+
api_key_name: str | None = None
47+
user_email: str | None = None
4548

4649

4750
metadata = IntegrationMetadata(
@@ -106,19 +109,44 @@ def build_integration(self, state: Mapping[str, Any]) -> IntegrationData:
106109
raise IntegrationConfigurationError("Missing configuration data")
107110

108111
webhook_secret = generate_token()
112+
api_key = config["api_key"]
113+
114+
api_key_name = None
115+
user_email = None
116+
try:
117+
client = CursorAgentClient(api_key=api_key, webhook_secret=webhook_secret)
118+
cursor_metadata = client.get_api_key_metadata()
119+
api_key_name = cursor_metadata.apiKeyName
120+
user_email = cursor_metadata.userEmail
121+
except (HTTPError, ApiError):
122+
self.get_logger().exception(
123+
"cursor.build_integration.metadata_fetch_failed",
124+
)
125+
except ValidationError:
126+
self.get_logger().exception(
127+
"cursor.build_integration.metadata_validation_failed",
128+
)
129+
130+
integration_name = (
131+
f"Cursor Cloud Agent - {user_email}/{api_key_name}"
132+
if user_email and api_key_name
133+
else "Cursor Cloud Agent"
134+
)
109135

110136
metadata = CursorIntegrationMetadata(
111137
domain_name="cursor.sh",
112-
api_key=config["api_key"],
138+
api_key=api_key,
113139
webhook_secret=webhook_secret,
140+
api_key_name=api_key_name,
141+
user_email=user_email,
114142
)
115143

116144
return {
117145
# 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.
118146
# 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)
119147
# 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
120148
"external_id": uuid.uuid4().hex,
121-
"name": "Cursor Agent",
149+
"name": integration_name,
122150
"metadata": metadata.dict(),
123151
}
124152

@@ -170,6 +198,18 @@ def get_client(self):
170198
webhook_secret=self.webhook_secret,
171199
)
172200

201+
def get_dynamic_display_information(self) -> Mapping[str, Any] | None:
202+
"""Return metadata to display in the configurations list."""
203+
metadata = CursorIntegrationMetadata.parse_obj(self.model.metadata or {})
204+
205+
display_info = {}
206+
if metadata.api_key_name:
207+
display_info["api_key_name"] = metadata.api_key_name
208+
if metadata.user_email:
209+
display_info["user_email"] = metadata.user_email
210+
211+
return display_info if display_info else None
212+
173213
@property
174214
def webhook_secret(self) -> str:
175215
return CursorIntegrationMetadata.parse_obj(self.model.metadata).webhook_secret

src/sentry/integrations/cursor/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
from pydantic import BaseModel
44

55

6+
class CursorApiKeyMetadata(BaseModel):
7+
apiKeyName: str
8+
createdAt: str
9+
userEmail: str
10+
11+
612
class CursorAgentLaunchRequestPrompt(BaseModel):
713
text: str
814
images: list[dict] = []

tests/sentry/integrations/cursor/test_integration.py

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
CursorAgentIntegration,
1212
CursorAgentIntegrationProvider,
1313
)
14-
from sentry.shared_integrations.exceptions import IntegrationConfigurationError
14+
from sentry.shared_integrations.exceptions import ApiError, IntegrationConfigurationError
1515
from sentry.testutils.cases import IntegrationTestCase
1616
from sentry.testutils.silo import assume_test_silo_mode_of
1717

@@ -38,6 +38,63 @@ def test_build_integration_stores_metadata(provider):
3838
assert metadata["webhook_secret"] == "hook-secret"
3939

4040

41+
def test_build_integration_fetches_and_stores_api_key_metadata(provider):
42+
"""Test that build_integration fetches metadata from /v0/me and stores it"""
43+
from sentry.integrations.cursor.models import CursorApiKeyMetadata
44+
45+
fake_uuid = UUID("22222222-3333-4444-5555-666666666666")
46+
mock_metadata = CursorApiKeyMetadata(
47+
apiKeyName="Production API Key",
48+
createdAt="2024-01-15T10:30:00Z",
49+
userEmail="[email protected]",
50+
)
51+
52+
with (
53+
patch("sentry.integrations.cursor.integration.uuid.uuid4", return_value=fake_uuid),
54+
patch("sentry.integrations.cursor.integration.generate_token", return_value="hook-secret"),
55+
patch(
56+
"sentry.integrations.cursor.client.CursorAgentClient.get_api_key_metadata"
57+
) as mock_get_metadata,
58+
):
59+
mock_get_metadata.return_value = mock_metadata
60+
integration_data = provider.build_integration(state={"config": {"api_key": "cursor-api"}})
61+
62+
# Verify metadata was fetched
63+
mock_get_metadata.assert_called_once()
64+
65+
# Verify metadata is stored
66+
metadata = integration_data["metadata"]
67+
assert metadata["api_key_name"] == "Production API Key"
68+
assert metadata["user_email"] == "[email protected]"
69+
70+
# Verify integration name includes API key name
71+
assert (
72+
integration_data["name"] == "Cursor Cloud Agent - [email protected]/Production API Key"
73+
)
74+
75+
76+
def test_build_integration_fallback_on_metadata_fetch_failure(provider):
77+
"""Test that build_integration falls back gracefully if metadata fetch fails"""
78+
fake_uuid = UUID("33333333-4444-5555-6666-777777777777")
79+
80+
with (
81+
patch("sentry.integrations.cursor.integration.uuid.uuid4", return_value=fake_uuid),
82+
patch("sentry.integrations.cursor.integration.generate_token", return_value="hook-secret"),
83+
patch(
84+
"sentry.integrations.cursor.client.CursorAgentClient.get_api_key_metadata"
85+
) as mock_get_metadata,
86+
):
87+
# Simulate API call failure
88+
mock_get_metadata.side_effect = ApiError("API Error", 500)
89+
integration_data = provider.build_integration(state={"config": {"api_key": "cursor-api"}})
90+
91+
# Verify integration was still created with fallback name
92+
assert integration_data["name"] == "Cursor Cloud Agent"
93+
metadata = integration_data["metadata"]
94+
assert metadata["api_key_name"] is None
95+
assert metadata["user_email"] is None
96+
97+
4198
def test_build_integration_stores_api_key_and_webhook_secret(provider):
4299
"""Test that build_integration stores both API key and webhook secret"""
43100
integration_data = provider.build_integration(state={"config": {"api_key": "new-api"}})
@@ -266,7 +323,7 @@ def test_build_integration_creates_unique_installations(self):
266323

267324
# All should have the same basic structure
268325
for integration_dict in [integration_dict_1, integration_dict_2, integration_dict_3]:
269-
assert integration_dict["name"] == "Cursor Agent"
326+
assert integration_dict["name"] == "Cursor Cloud Agent"
270327
assert "external_id" in integration_dict
271328
assert "metadata" in integration_dict
272329
assert integration_dict["metadata"]["domain_name"] == "cursor.sh"
@@ -280,3 +337,53 @@ def test_build_integration_creates_unique_installations(self):
280337

281338
webhook_secrets = {webhook_secret_1, webhook_secret_2, webhook_secret_3}
282339
assert len(webhook_secrets) == 3, "Each integration should have a unique webhook secret"
340+
341+
def test_get_dynamic_display_information(self):
342+
"""Test that get_dynamic_display_information returns metadata"""
343+
integration = self.create_integration(
344+
organization=self.organization,
345+
provider="cursor",
346+
name="Cursor Agent - Production Key",
347+
external_id="cursor",
348+
metadata={
349+
"api_key": "test_api_key",
350+
"webhook_secret": "test_secret",
351+
"domain_name": "cursor.sh",
352+
"api_key_name": "Production Key",
353+
"user_email": "[email protected]",
354+
},
355+
)
356+
357+
installation = cast(
358+
CursorAgentIntegration,
359+
integration.get_installation(organization_id=self.organization.id),
360+
)
361+
362+
display_info = installation.get_dynamic_display_information()
363+
364+
assert display_info is not None
365+
assert display_info["api_key_name"] == "Production Key"
366+
assert display_info["user_email"] == "[email protected]"
367+
368+
def test_get_dynamic_display_information_returns_none_when_no_metadata(self):
369+
"""Test that get_dynamic_display_information returns None when metadata is missing"""
370+
integration = self.create_integration(
371+
organization=self.organization,
372+
provider="cursor",
373+
name="Cursor Agent",
374+
external_id="cursor",
375+
metadata={
376+
"api_key": "test_api_key",
377+
"webhook_secret": "test_secret",
378+
"domain_name": "cursor.sh",
379+
},
380+
)
381+
382+
installation = cast(
383+
CursorAgentIntegration,
384+
integration.get_installation(organization_id=self.organization.id),
385+
)
386+
387+
display_info = installation.get_dynamic_display_information()
388+
389+
assert display_info is None

0 commit comments

Comments
 (0)