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: 15 additions & 4 deletions src/sentry/integrations/cursor_origin/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,18 +356,25 @@ def track_response_data(
super().track_response_data(code, error, resp, extra)

def _paginate[T](
self, path: str, collection_key: str, params: dict[str, Any] | None = None
self,
path: str,
collection_key: str,
params: dict[str, Any] | None = None,
limit: int | None = None,
) -> list[T]:
results: list[T] = []
page_token: str | None = None
page_size = min(PAGE_SIZE, limit) if limit else PAGE_SIZE

for _ in range(self.page_number_limit):
request_params: dict[str, Any] = {"pageSize": PAGE_SIZE, **(params or {})}
request_params: dict[str, Any] = {"pageSize": page_size, **(params or {})}
if page_token:
request_params["pageToken"] = page_token

response = self.get(path, params=request_params)
results.extend(response[collection_key])
if limit is not None and len(results) >= limit:
return results[:limit]

# Present on every page; empty on the last one.
page_token = response["nextPageToken"]
Expand All @@ -386,10 +393,14 @@ def get_repo(self, repo_full_name: str) -> OriginRepository:
def get_branches(self, repo_full_name: str) -> list[OriginBranch]:
return self._paginate(f"/repos/{repo_full_name}/branches", "branches")

def get_commits(self, repo_full_name: str, sha: str | None = None) -> list[OriginCommit]:
def get_commits(
self, repo_full_name: str, sha: str | None = None, limit: int | None = None
) -> list[OriginCommit]:
"""Return commits from `sha`, newest first, or from the default branch."""
params = {"sha": sha} if sha else None
return self._paginate(f"/repos/{repo_full_name}/commits", "commits", params=params)
return self._paginate(
f"/repos/{repo_full_name}/commits", "commits", params=params, limit=limit
)

def get_commit(self, repo_full_name: str, sha: str) -> OriginCommit:
"""Return a commit with aggregate stats."""
Expand Down
98 changes: 91 additions & 7 deletions src/sentry/integrations/cursor_origin/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
from collections.abc import Mapping, MutableMapping, Sequence
from typing import Any

from django.utils import timezone

from sentry import options
from sentry.integrations.cursor_origin.client import (
CursorOriginApiClient,
OriginCommit,
OriginCommitFile,
)
from sentry.integrations.cursor_origin.constants import CURSOR_ORIGIN_WEB_BASE_URL
from sentry.integrations.cursor_origin.integration import CursorOriginIntegration
from sentry.integrations.types import IntegrationProviderSlug
Expand All @@ -12,11 +20,18 @@
from sentry.models.repository import Repository
from sentry.organizations.services.organization.model import RpcOrganization
from sentry.plugins.providers import IntegrationRepositoryProvider
from sentry.plugins.providers.integration_repository import RepositoryConfig
from sentry.plugins.providers.integration_repository import (
CommitData,
CommitPatchFile,
RepositoryConfig,
)
from sentry.shared_integrations.exceptions import ApiError, IntegrationError

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

MAX_COMPARE_COMMITS_OPTION_KEY = "cursor-origin-app.fetch-commits.max-compare-commits"
RECENT_COMMIT_COUNT = 20


class CursorOriginRepositoryProvider(IntegrationRepositoryProvider[CursorOriginIntegration]):
name = "Cursor Origin"
Expand Down Expand Up @@ -56,12 +71,81 @@ def repository_external_slug(self, repo: Repository) -> str:

def compare_commits(
self, repo: Repository, start_sha: str | None, end_sha: str
) -> Sequence[Mapping[str, Any]]:
# Origin does expose List Commits and Compare Commits, but wiring release commit
# tracking to them is its own change. Raising rather than returning [] keeps this
# visible in Sentry's own errors: an empty list is indistinguishable from a
# repository that genuinely has no new commits.
raise NotImplementedError("Cursor Origin commit tracking is not implemented yet")
) -> Sequence[CommitData]:
installation = self.get_installation(repo.integration_id, repo.organization_id)
client = installation.get_client()
name = repo.config["name"]

try:
if start_sha is None:
commits = client.get_commits(name, sha=end_sha, limit=RECENT_COMMIT_COUNT)
else:
commits = self._commits_in_range(client, repo, name, start_sha, end_sha)
return [self._format_commit(client, name, commit) for commit in reversed(commits)]
except Exception as e:
installation.raise_error(e)

def _commits_in_range(
self,
client: CursorOriginApiClient,
repo: Repository,
name: str,
start_sha: str,
end_sha: str,
) -> list[OriginCommit]:
"""Return commits in the range, newest first"""
comparison = client.compare_commits(name, start_sha, end_sha)
ahead_by = comparison["aheadBy"]
if ahead_by <= 0:
return []

max_commits = options.get(MAX_COMPARE_COMMITS_OPTION_KEY)
if max_commits and ahead_by > max_commits:
logger.info(
"cursor_origin.fetch_commits.truncated",
extra={
"organization_id": repo.organization_id,
"repository": repo.name,
"start_sha": start_sha,
"end_sha": end_sha,
"ahead_by": ahead_by,
"truncated_count": max_commits,
},
)
ahead_by = max_commits

return client.get_commits(name, sha=end_sha, limit=ahead_by)

def _format_commit(
self, client: CursorOriginApiClient, name: str, commit: OriginCommit
) -> CommitData:
author = commit["commit"]["author"]
return {
"id": commit["sha"],
"repository": name,
"author_email": author["email"],
"author_name": author["name"][:128],
"message": commit["commit"]["message"],
# `format_date` gives None for an empty date, and `set_commits` sorts on this.
"timestamp": self.format_date(author["date"]) or timezone.now(),
"patch_set": self._patch_set(client.get_commit_files(name, commit["sha"])),
}

def _patch_set(self, files: Sequence[OriginCommitFile]) -> list[CommitPatchFile]:
"""File changes in the shape `Release.set_commits` expects."""
changes: list[CommitPatchFile] = []
for file in files:
status = file["status"]
if status == "modified":
changes.append({"path": file["filename"], "type": "M"})
elif status in ("added", "copied"):
changes.append({"path": file["filename"], "type": "A"})
elif status == "removed":
changes.append({"path": file["filename"], "type": "D"})
elif status == "renamed":
changes.append({"path": file["previousFilename"], "type": "D"})
changes.append({"path": file["filename"], "type": "A"})
return changes

def pull_request_url(self, repo: Repository, pull_request: PullRequest) -> str:
return f"{CURSOR_ORIGIN_WEB_BASE_URL}/{repo.name}/pull/{pull_request.key}"
6 changes: 6 additions & 0 deletions src/sentry/options/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,12 @@
# Cursor Origin Integration
register("cursor-origin-app.id", default="", flags=FLAG_AUTOMATOR_MODIFIABLE)
register("cursor-origin-app.private-key", default="", flags=FLAG_CREDENTIAL | FLAG_PRIORITIZE_DISK)
register(
"cursor-origin-app.fetch-commits.max-compare-commits",
type=Int,
default=500,
Comment thread
wedamija marked this conversation as resolved.
flags=FLAG_AUTOMATOR_MODIFIABLE,
)

# Github Enterprise Integration
register(
Expand Down
23 changes: 21 additions & 2 deletions src/sentry/plugins/providers/integration_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import logging
from collections.abc import Mapping, MutableMapping, Sequence
from datetime import timezone
from typing import Any, ClassVar, Generic, TypedDict, TypeVar, cast
from datetime import datetime, timezone
from typing import Any, ClassVar, Generic, Literal, TypedDict, TypeVar, cast

from dateutil.parser import parse as parse_date
from rest_framework import status
Expand Down Expand Up @@ -38,6 +38,25 @@ class RepositoryConfig(TypedDict):
integration_id: int


class CommitPatchFile(TypedDict):
"""One file a commit touched. `type` is a `CommitFileChange.type` choice."""

path: str
type: Literal["A", "D", "M"]


class CommitData(TypedDict):
"""A commit in the shape `Release.set_commits` consumes."""

id: str
repository: str
author_email: str
author_name: str
message: str
timestamp: datetime
patch_set: Sequence[CommitPatchFile]


class RepoExistsError(SentryAPIException):
status_code = status.HTTP_400_BAD_REQUEST
code = "repo_exists"
Expand Down
14 changes: 14 additions & 0 deletions tests/sentry/integrations/cursor_origin/test_client_reads.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,20 @@ def test_get_commits_starts_from_a_ref(self) -> None:
assert [commit["sha"] for commit in commits] == ["abc"]
assert "sha=main" in responses.calls[0].request.url

@responses.activate
def test_a_limited_read_asks_for_no_more_than_it_wants(self) -> None:
responses.add(
responses.GET,
f"{CURSOR_ORIGIN_API_BASE_URL}/repos/{REPO}/commits",
json={"commits": [{"sha": f"c{i}"} for i in range(20)], "nextPageToken": "page-2"},
)

commits = self.origin_client.get_commits(REPO, sha="main", limit=20)

assert len(commits) == 20
assert "pageSize=20" in responses.calls[0].request.url
assert len(responses.calls) == 1

@responses.activate
def test_get_commits_defaults_to_the_default_branch(self) -> None:
"""Origin reads an absent `sha` as the repository's default branch."""
Expand Down
Loading
Loading