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
8 changes: 8 additions & 0 deletions bin/github-client
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Commands:
get-pull-request-review <pull_request_id> <review_id>
get-review-comments <pull_request_id> <review_id> [--cursor <n>] [--per-page <n>]
get-pull-request-url <id>
get-git-remote
mark-pull-request-ready-for-review <id>
mark-pull-request-as-draft <id>
get-issue <id>
Expand Down Expand Up @@ -129,6 +130,7 @@ from scm.types import (
GetFileContentProtocol,
GetFileUrlProtocol,
GetGitRefProtocol,
GetGitRemoteProtocol,
GetIssueCommentsProtocol,
GetIssueProtocol,
GetPullRequestCommentsProtocol,
Expand Down Expand Up @@ -283,6 +285,8 @@ def main() -> None:
p = sub.add_parser("get-pull-request-url")
p.add_argument("id")

sub.add_parser("get-git-remote")

p = sub.add_parser("mark-pull-request-ready-for-review")
p.add_argument("id")

Expand Down Expand Up @@ -567,6 +571,10 @@ def main() -> None:
assert isinstance(scm, GetPullRequestUrlProtocol)
dump(scm.get_pull_request_url(args.id))

elif args.command == "get-git-remote":
assert isinstance(scm, GetGitRemoteProtocol)
dump(scm.get_git_remote())

elif args.command == "mark-pull-request-ready-for-review":
assert isinstance(scm, MarkPullRequestDraftStateProtocol)
scm.mark_pull_request_ready_for_review(args.id)
Expand Down
8 changes: 8 additions & 0 deletions bin/gitlab-client
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Commands:
get-pull-request-diff <id>
get-pull-request-review-threads <id> [--include-reactions]
get-pull-request-url <id>
get-git-remote
get-issue <id>
create-issue <title> <body> [--assignee <user_id> ...] [--label <name> ...]
update-issue <issue_id> [--state open|closed] [--assignee <user_id> ...] [--label <name> ...]
Expand Down Expand Up @@ -117,6 +118,7 @@ from scm.types import (
GetDirectoryContentsProtocol,
GetFileContentProtocol,
GetFileUrlProtocol,
GetGitRemoteProtocol,
GetIssueCommentsProtocol,
GetIssueProtocol,
GetPullRequestCommentsProtocol,
Expand Down Expand Up @@ -248,6 +250,8 @@ def main():
p = sub.add_parser("get-pull-request-url")
p.add_argument("id")

sub.add_parser("get-git-remote")

p = sub.add_parser("get-issue")
p.add_argument("id")

Expand Down Expand Up @@ -453,6 +457,10 @@ def main():
assert isinstance(scm, GetPullRequestUrlProtocol)
dump(scm.get_pull_request_url(args.id))

elif args.command == "get-git-remote":
assert isinstance(scm, GetGitRemoteProtocol)
dump(scm.get_git_remote())

elif args.command == "get-issue":
assert isinstance(scm, GetIssueProtocol)
dump(scm.get_issue(args.id))
Expand Down
8 changes: 8 additions & 0 deletions src/scm/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
GetFullTreeProtocol,
GetGitCommitProtocol,
GetGitRefProtocol,
GetGitRemoteProtocol,
GetIssueCommentReactionsProtocol,
GetIssueCommentsProtocol,
GetIssueProtocol,
Expand Down Expand Up @@ -106,6 +107,7 @@
GitBlob,
GitCommitObject,
GitRef,
GitRemote,
GitRepository,
GitTree,
InputTreeEntry,
Expand Down Expand Up @@ -528,6 +530,11 @@ def get_pull_request_url(scm: GetPullRequestUrlProtocol, pull_request_id: str) -
return scm.get_pull_request_url(pull_request_id)


def get_git_remote(scm: GetGitRemoteProtocol) -> GitRemote:
"""Return the repository's git smart-HTTP endpoint and the basic-auth username its provider pairs with a token."""
return scm.get_git_remote()


def create_git_blob(scm: CreateGitBlobProtocol, content: str, encoding: str) -> ActionResult[GitBlob]:
"""Create a git blob object."""
return scm.create_git_blob(content, encoding)
Expand Down Expand Up @@ -1202,6 +1209,7 @@ def download_archive(
"get_file_url",
"get_git_commit",
"get_git_ref",
"get_git_remote",
"get_issue_comment_reactions",
"get_issue_comments",
"get_issue_reactions",
Expand Down
9 changes: 9 additions & 0 deletions src/scm/providers/cursor_origin/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
GitCommitObject,
GitCommitTree,
GitRef,
GitRemote,
GitRepository,
GitTree,
MoveCommitAction,
Expand Down Expand Up @@ -79,6 +80,8 @@
PULL_REQUEST_TEMPLATE_FILENAME = "pull_request_template.md"
PULL_REQUEST_TEMPLATE_DIRNAME = "pull_request_template"
CURSOR_ORIGIN_WEB_BASE_URL = "https://cursor.com/codebase"
# Git over HTTPS is served from its own host, not the web UI's.
CURSOR_ORIGIN_GIT_BASE_URL = "https://origin.cursor.com"
# Origin has no no-reply address for an app.
CURSOR_ORIGIN_APP_COMMIT_EMAIL = "noreply@sentry.io"
PAGE_TOKEN_PARAM = "pageToken"
Expand Down Expand Up @@ -354,6 +357,12 @@ def get_commits_url(
def get_pull_request_url(self, pull_request_id: str) -> str:
return f"{self._web_base_url}/{self.repository_path}/pull/{pull_request_id}"

def get_git_remote(self) -> GitRemote:
# Origin's git endpoint takes an installation token as the basic-auth password and refuses a bearer header.
return GitRemote(
url=f"{CURSOR_ORIGIN_GIT_BASE_URL}/{self.repository_path}.git", basic_auth_username="x-access-token"
)

def get_file_content(
self,
path: str,
Expand Down
12 changes: 10 additions & 2 deletions src/scm/providers/github/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
GitCommitObject,
GitCommitTree,
GitRef,
GitRemote,
GitRepository,
GitTree,
InputTreeEntry,
Expand Down Expand Up @@ -437,12 +438,14 @@ def __init__(
client: ApiClient,
organization_id: int,
repository: Repository,
web_base_url: str = GITHUB_WEB_BASE_URL,
web_base_url: str | None = None,
) -> None:
self.client = client
self.organization_id = organization_id
self.repository = repository
self._web_base_url = web_base_url
# A GitHub Enterprise repository carries its instance's URL. Falling back to it keeps a caller that builds
# the provider without one from pointing web links, and the git remote a token is sent to, at github.com.
self._web_base_url = web_base_url or repository["web_base_url"] or GITHUB_WEB_BASE_URL

def request(
self,
Expand Down Expand Up @@ -1031,6 +1034,11 @@ def get_commits_url(
def get_pull_request_url(self, pull_request_id: str) -> str:
return f"{self._web_base_url}/{self.repository['name']}/pull/{pull_request_id}"

def get_git_remote(self) -> GitRemote:
return GitRemote(
url=f"{self._web_base_url}/{self.repository['name']}.git", basic_auth_username="x-access-token"
)

def create_git_blob(self, content: str, encoding: str) -> ActionResult[GitBlob]:
response = self.post(
f"/repos/{self.repository['name']}/git/blobs",
Expand Down
8 changes: 7 additions & 1 deletion src/scm/providers/gitlab/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
GitCommitObject,
GitCommitTree,
GitRef,
GitRemote,
GitRepository,
GitTree,
Issue,
Expand Down Expand Up @@ -240,7 +241,9 @@ def __init__(self, client: ApiClient, organization_id: int, repository: Reposito
raise MalformedExternalId()

netloc, self.project_id = repository["external_id"].rsplit(":", maxsplit=1)
self.web_base_url = f"https://{netloc}"
# Sentry sends the instance's URL with the scheme and any path prefix it is served under. The external ID
# holds only the host, which is enough for an instance served at the root over https.
self.web_base_url = repository["web_base_url"] or f"https://{netloc}"

@property
def web_repository_path(self) -> str:
Expand Down Expand Up @@ -744,6 +747,9 @@ def get_commits_url(
def get_pull_request_url(self, pull_request_id: str) -> str:
return f"{self.web_base_url}/{self.web_repository_path}/-/merge_requests/{pull_request_id}"

def get_git_remote(self) -> GitRemote:
return GitRemote(url=f"{self.web_base_url}/{self.web_repository_path}.git", basic_auth_username="oauth2")

def get_tree(
self,
tree_sha: SHA,
Expand Down
4 changes: 4 additions & 0 deletions src/scm/test_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
GitCommitObject,
GitCommitTree,
GitRef,
GitRemote,
GitTree,
InputTreeEntry,
Issue,
Expand Down Expand Up @@ -1190,6 +1191,9 @@ def get_commits_url(
def get_pull_request_url(self, pull_request_id: str) -> str:
return f"https://github.com/test-org/test-repo/pull/{pull_request_id}"

def get_git_remote(self) -> GitRemote:
return GitRemote(url="https://github.com/test-org/test-repo.git", basic_auth_username="x-access-token")

# Git blob operations

def create_git_blob(self, content: str, encoding: str) -> ActionResult[GitBlob]:
Expand Down
15 changes: 15 additions & 0 deletions src/scm/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ class ArchiveLink(TypedDict):
headers: dict[str, str]


class GitRemote(TypedDict):
"""Where a repository's git smart-HTTP endpoint lives and how a token authenticates against it."""

url: str
"""The repository's clone URL, e.g. ``https://github.com/getsentry/sentry.git``."""
basic_auth_username: str
"""Sent with the token as the Basic auth username, e.g. ``x-access-token`` on GitHub."""


type BuildStatus = Literal["pending", "running", "completed"]
"""The lifecycle stage of a CI build.

Expand Down Expand Up @@ -1100,6 +1109,11 @@ class GetPullRequestUrlProtocol(Protocol):
def get_pull_request_url(self, pull_request_id: str) -> str: ...


@runtime_checkable
class GetGitRemoteProtocol(Protocol):
def get_git_remote(self) -> GitRemote: ...


# Commit Protocols


Expand Down Expand Up @@ -1675,6 +1689,7 @@ def update_and_collapse_pull_request_comment(
GetFileUrlProtocol,
GetGitCommitProtocol,
GetGitRefProtocol,
GetGitRemoteProtocol,
GetIssueCommentReactionsProtocol,
GetIssueCommentsProtocol,
GetIssueProtocol,
Expand Down
7 changes: 7 additions & 0 deletions tests/unit/provider/test_cursor_origin.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
ChmodCommitAction,
CommitAuthorParam,
DeleteCommitAction,
GitRemote,
MoveCommitAction,
Repository,
WriteCommitAction,
Expand Down Expand Up @@ -1083,6 +1084,12 @@ def test_a_pull_request_url(self, provider: CursorOriginProvider) -> None:
def test_a_commit_url(self, provider: CursorOriginProvider) -> None:
assert provider.get_commit_url("abc123") == f"https://cursor.com/codebase/{REPO}/commit/abc123"

def test_a_git_remote(self, provider: CursorOriginProvider) -> None:
# Git is served from origin.cursor.com rather than the web UI's cursor.com/codebase.
assert provider.get_git_remote() == GitRemote(
url=f"https://origin.cursor.com/{REPO}.git", basic_auth_username="x-access-token"
)


def _commit_raw(sha: str, date: str = "2026-08-01T09:30:00Z") -> dict[str, Any]:
identity = {"name": "Jane Doe", "email": "jane@example.com", "date": date}
Expand Down
36 changes: 36 additions & 0 deletions tests/unit/provider/test_github.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
CredentialsSet,
DeleteCommitAction,
DiffLine,
GitRemote,
MoveCommitAction,
Repository,
WriteCommitAction,
Expand Down Expand Up @@ -2081,6 +2082,40 @@ def test_get_pull_request_url_builds_pr_url() -> None:
assert provider.get_pull_request_url("42") == "https://github.com/test-org/test-repo/pull/42"


def test_get_git_remote_builds_clone_url() -> None:
provider = GitHubProvider(MagicMock(spec=ApiClient), organization_id=1, repository=make_repository())

assert provider.get_git_remote() == GitRemote(
url="https://github.com/test-org/test-repo.git", basic_auth_username="x-access-token"
)


def test_ghe_web_base_url_used_in_git_remote() -> None:
provider = GitHubProvider(
MagicMock(spec=ApiClient),
organization_id=1,
repository=make_repository(),
web_base_url="https://github.example.com",
)

assert provider.get_git_remote() == GitRemote(
url="https://github.example.com/test-org/test-repo.git", basic_auth_username="x-access-token"
)


def test_ghe_web_base_url_defaults_to_the_repositorys() -> None:
# Built without a web_base_url, a GitHub Enterprise provider would otherwise send its token to github.com.
repository = {
**make_repository(),
"provider_name": "github_enterprise",
"web_base_url": "https://github.example.com",
}
provider = GitHubProvider(MagicMock(spec=ApiClient), organization_id=1, repository=repository) # type: ignore[arg-type]

assert provider.get_git_remote()["url"] == "https://github.example.com/test-org/test-repo.git"
assert provider.get_pull_request_url("42") == "https://github.example.com/test-org/test-repo/pull/42"


def test_ghe_web_base_url_used_in_url_methods() -> None:
provider = GitHubProvider(
MagicMock(spec=ApiClient),
Expand Down Expand Up @@ -3658,6 +3693,7 @@ def test_public_methods_are_accounted_for() -> None:
"get_commit_url",
"get_commits_url",
"get_pull_request_url",
"get_git_remote",
"create_commit",
"update_issue",
"get_thread_id_from_review_comment_unique_id",
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/provider/test_gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
ChmodCommitAction,
DeleteCommitAction,
DiffLine,
GitRemote,
MoveCommitAction,
Repository,
WriteCommitAction,
Expand Down Expand Up @@ -13969,6 +13970,48 @@ def test_web_repository_path_used_in_commit_and_mr_urls(client: ApiClient) -> No
)


def test_web_links_keep_the_instances_path_prefix(client: ApiClient) -> None:
repository = {**_make_gitlab_provider(client).repository, "web_base_url": "https://example.com/gitlab"}
provider = GitLabProvider(client=client, organization_id=1, repository=repository) # type: ignore[arg-type]

assert provider.get_commit_url("abc123") == "https://example.com/gitlab/test-repo/-/commit/abc123"


def test_get_git_remote_is_the_repository_path_on_its_instance(client: ApiClient) -> None:
provider = _make_gitlab_provider(client, name="GitLab.org / GitLab FOSS", path="gitlab-org/gitlab-foss")

assert provider.get_git_remote() == GitRemote(
url="https://gitlab.com/gitlab-org/gitlab-foss.git", basic_auth_username="oauth2"
)


def test_get_git_remote_on_a_self_managed_instance(client: ApiClient) -> None:
provider = GitLabProvider(
client=client,
organization_id=1,
repository={**_make_gitlab_provider(client).repository, "external_id": "gitlab.example.com:8443:7"},
)

assert provider.get_git_remote() == GitRemote(
url="https://gitlab.example.com:8443/test-repo.git", basic_auth_username="oauth2"
)


def test_get_git_remote_keeps_the_instances_path_prefix(client: ApiClient) -> None:
repository = {**_make_gitlab_provider(client).repository, "web_base_url": "https://example.com/gitlab"}
provider = GitLabProvider(client=client, organization_id=1, repository=repository) # type: ignore[arg-type]

assert provider.get_git_remote()["url"] == "https://example.com/gitlab/test-repo.git"


@pytest.mark.parametrize("path", [None, ""])
def test_get_git_remote_needs_the_repository_path(client: ApiClient, path: str | None) -> None:
provider = _make_gitlab_provider(client, name="GitLab.org / GitLab FOSS", path=path)

with pytest.raises(MissingRepositoryPath):
provider.get_git_remote()


def test_get_file_url_builds_blob_url(provider: GitLabProvider):
assert provider.get_file_url("src/main.py", "abc123") == ("https://gitlab.com/test-repo/-/blob/abc123/src/main.py")
assert provider.get_file_url("src/main.py", "abc123", start_line=10) == (
Expand Down
Loading
Loading