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
17 changes: 17 additions & 0 deletions src/sentry/integrations/cursor_origin/authors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from __future__ import annotations

from sentry.models.commitauthor import CommitAuthor

MAX_AUTHOR_EMAIL_LENGTH = 75


def get_or_create_commit_author(organization_id: int, email: str, name: str) -> CommitAuthor | None:
if not email or len(email) > MAX_AUTHOR_EMAIL_LENGTH:
return None

author, _ = CommitAuthor.objects.get_or_create(
organization_id=organization_id,
email=email,
defaults={"name": name[:128]},
)
return author
96 changes: 96 additions & 0 deletions src/sentry/integrations/cursor_origin/pull_request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
from __future__ import annotations

import logging
from collections.abc import Mapping, Sequence
from typing import Any

from sentry.integrations.cursor_origin.authors import get_or_create_commit_author
from sentry.integrations.cursor_origin.handlers import WebhookEventHandler
from sentry.integrations.cursor_origin.repository import active_repositories
from sentry.integrations.cursor_origin.webhook_types import PullRequestEvent
from sentry.integrations.services.integration.model import (
RpcIntegration,
RpcOrganizationIntegration,
)
from sentry.integrations.types import IntegrationProviderSlug
from sentry.integrations.utils.metrics import IntegrationWebhookEventType
from sentry.models.pullrequest import PullRequestLifecycleState
from sentry.models.repository import Repository
from sentry.pr_metrics.lifecycle_mapping import update_pull_request_from_scm_snapshot
from sentry.utils import metrics

logger = logging.getLogger("sentry.integrations.cursor_origin")


def lifecycle_state(event: PullRequestEvent) -> str:
if event.pull_request.merged:
return PullRequestLifecycleState.MERGED
if event.pull_request.state == "closed":
return PullRequestLifecycleState.CLOSED
return PullRequestLifecycleState.OPEN


class PullRequestLifecycleHandler(WebhookEventHandler):
"""Record a pull request from any of Origin's snapshot events."""

EVENT_TYPE = IntegrationWebhookEventType.MERGE_REQUEST

def __call__(
self,
payload: Mapping[str, Any],
delivery_id: str,
integration: RpcIntegration,
org_integrations: Sequence[RpcOrganizationIntegration],
) -> None:
event = PullRequestEvent.from_payload(payload)

repositories = active_repositories(event.repository_id, org_integrations)
if not repositories:
logger.info(
"cursor_origin.pull_request.unknown_repository",
extra={"delivery_id": delivery_id, "external_id": event.repository_id},
)
metrics.incr("cursor_origin.pull_request.unknown_repository", sample_rate=1.0)
return

for repo in repositories:
self._record(repo, event, delivery_id)

def _record(self, repo: Repository, event: PullRequestEvent, delivery_id: str) -> None:
pull_request = event.pull_request
state = lifecycle_state(event)
email, name = pull_request.author.email_and_name()
author = get_or_create_commit_author(repo.organization_id, email, name)

Check warning on line 63 in src/sentry/integrations/cursor_origin/pull_request.py

View check run for this annotation

@sentry/warden / warden: sentry-backend-bugs

Empty PR author causes an uncaught AssertionError in pull-request webhooks

`PullRequestAuthor` documents that exactly one of `user`, `app`, or `serviceAccount` is required, but all three fields are optional and no validator enforces that invariant. A payload containing `author: {}` therefore parses successfully, then `email_and_name()` reaches its assertion and raises `AssertionError`. The webhook endpoint converts only `OriginPayloadError` into HTTP 400, so the malformed verified payload produces a 500 and is retried. Enforce exactly one author variant during Pydantic validation so `PullRequestEvent.from_payload()` converts it to `OriginPayloadError`.

_, created = update_pull_request_from_scm_snapshot(
provider=IntegrationProviderSlug.CURSOR_ORIGIN.value,
organization_id=repo.organization_id,
repository_id=repo.id,
key=pull_request.number,
defaults={
"organization_id": repo.organization_id,
"title": pull_request.title,
"author": author,
"message": pull_request.body,
"merge_commit_sha": pull_request.merge_commit_sha or None,
"head_commit_sha": pull_request.head.sha,
"opened_at": pull_request.created_at,
"closed_at": pull_request.closed_at,
"merged_at": pull_request.merged_at,
"provider_updated_at": pull_request.updated_at,
"state": state,
"draft": pull_request.draft,
},
event_state=state,
event_updated_at=pull_request.updated_at,
)

if created:
logger.info(
"cursor_origin.pull_request.created",
extra={
"delivery_id": delivery_id,
"repository_id": repo.id,
"key": pull_request.number,
},
)
19 changes: 4 additions & 15 deletions src/sentry/integrations/cursor_origin/push.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from sentry import options
from sentry.constants import ObjectStatus
from sentry.integrations.cursor_origin.authors import get_or_create_commit_author
from sentry.integrations.cursor_origin.client import CursorOriginApiClient
from sentry.integrations.cursor_origin.handlers import WebhookEventHandler
from sentry.integrations.cursor_origin.repository import (
Expand All @@ -23,7 +24,6 @@
)
from sentry.integrations.utils.metrics import IntegrationWebhookEventType
from sentry.models.commit import Commit
from sentry.models.commitauthor import CommitAuthor
from sentry.models.commitfilechange import CommitFileChange, post_bulk_create
from sentry.models.repository import Repository
from sentry.plugins.providers.integration_repository import IntegrationRepositoryProvider
Expand All @@ -33,8 +33,6 @@

EMPTY_SHA = "0" * 40

MAX_AUTHOR_EMAIL_LENGTH = 75


class RepositoryPushedHandler(WebhookEventHandler):
"""Record the commits a push added.
Expand Down Expand Up @@ -155,7 +153,9 @@ def _record(
if Commit.objects.filter(repository_id=repo.id, key=commit.sha).exists():
return

author = self._author(repo, commit)
author = get_or_create_commit_author(
repo.organization_id, commit.author_email, commit.author_name
)
changes = file_changes_from(client.get_commit_files(repo.config["name"], commit.sha))

with transaction.atomic(router.db_for_write(Commit)):
Expand Down Expand Up @@ -184,14 +184,3 @@ def _record(
if rows:
CommitFileChange.objects.bulk_create(rows, ignore_conflicts=True)
post_bulk_create(rows)

def _author(self, repo: Repository, commit: PushedCommit) -> CommitAuthor | None:
if not commit.author_email or len(commit.author_email) > MAX_AUTHOR_EMAIL_LENGTH:
return None

commit_author, _ = CommitAuthor.objects.get_or_create(
organization_id=repo.organization_id,
email=commit.author_email,
defaults={"name": commit.author_name[:128]},
)
return commit_author
7 changes: 7 additions & 0 deletions src/sentry/integrations/cursor_origin/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
WebhookEventHandler,
)
from sentry.integrations.cursor_origin.keys import signing_keys_for
from sentry.integrations.cursor_origin.pull_request import PullRequestLifecycleHandler
from sentry.integrations.cursor_origin.push import RepositoryPushedHandler
from sentry.integrations.cursor_origin.repository_events import RepositoryMetadataUpdatedHandler
from sentry.integrations.cursor_origin.webhook_types import OriginPayloadError
Expand Down Expand Up @@ -140,6 +141,12 @@ def verify_delivery(request: HttpRequest, body: bytes) -> Verification:
"installation.suspended": InstallationRemovedHandler,
"installation.unsuspended": InstallationRestoredHandler,
"installation.updated": InstallationUpdatedHandler,
"pull_request.closed": PullRequestLifecycleHandler,
"pull_request.created": PullRequestLifecycleHandler,
"pull_request.merged": PullRequestLifecycleHandler,
"pull_request.metadata.updated": PullRequestLifecycleHandler,
"pull_request.published": PullRequestLifecycleHandler,
"pull_request.reopened": PullRequestLifecycleHandler,
"repository.metadata.updated": RepositoryMetadataUpdatedHandler,
"repository.pushed": RepositoryPushedHandler,
}
Expand Down
76 changes: 75 additions & 1 deletion src/sentry/integrations/cursor_origin/webhook_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from collections.abc import Mapping
from datetime import datetime
from typing import Any
from typing import Any, Literal

from pydantic import BaseModel, Field, ValidationError, validator

Expand Down Expand Up @@ -111,3 +111,77 @@ def from_payload(cls, payload: Mapping[str, Any]) -> RepositoryMetadataEvent:
return cls.parse_obj(payload)
except ValidationError as e:
raise OriginPayloadError(str(e)) from e


class PullRequestHead(OriginModel):
sha: str = Field(min_length=1)


class PullRequestUser(OriginModel):
email: str = Field(min_length=1)
display_name: str = Field(default="", alias="displayName")


class PullRequestApp(OriginModel):
id: str = Field(min_length=1)
display_name: str = Field(default="", alias="displayName")


class PullRequestServiceAccount(OriginModel):
id: str = Field(min_length=1)


class PullRequestAuthor(OriginModel):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to detect if it's a user, app or service account? Like a "type" field or something?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no type field explicitly, but the response does have author.user, author.app, author.serviceAccount to differentiate.

Modifying this to include these options in the author too

"""Exactly one of a user, an app or a service account."""

user: PullRequestUser | None = None
app: PullRequestApp | None = None
service_account: PullRequestServiceAccount | None = Field(default=None, alias="serviceAccount")

def email_and_name(self) -> tuple[str, str]:
if self.user is not None:
return self.user.email, self.user.display_name
if self.app is not None:
return f"{self.app.id}@localhost", self.app.display_name or self.app.id
assert self.service_account is not None
Comment thread
wedamija marked this conversation as resolved.
return f"{self.service_account.id}@localhost", self.service_account.id


class PullRequest(OriginModel):
"""Origin's own id is not kept: it is a prefixed string, and
`PullRequest.external_id` is an integer column."""

number: str = Field(min_length=1)
title: str
body: str
state: Literal["open", "closed"]
draft: bool
merged: bool
head: PullRequestHead
merge_commit_sha: str = Field(alias="mergeCommitSha")
Comment thread
wedamija marked this conversation as resolved.
author: PullRequestAuthor
created_at: datetime | None = Field(..., alias="createdAt")
updated_at: datetime | None = Field(..., alias="updatedAt")
closed_at: datetime | None = Field(..., alias="closedAt")
merged_at: datetime | None = Field(..., alias="mergedAt")

@validator("created_at", "updated_at", "closed_at", "merged_at", pre=True)
def _absent_date(cls, value: Any) -> Any:
"""Origin sends an empty string for an unset date."""
return value or None


class PullRequestEvent(OriginModel):
repository: Repository
pull_request: PullRequest = Field(alias="pullRequest")

@property
def repository_id(self) -> str:
return self.repository.id

@classmethod
def from_payload(cls, payload: Mapping[str, Any]) -> PullRequestEvent:
try:
return cls.parse_obj(payload)
except ValidationError as e:
raise OriginPayloadError(str(e)) from e
Loading
Loading