diff --git a/bin/github-client b/bin/github-client index 8b96aa8..38ce0da 100755 --- a/bin/github-client +++ b/bin/github-client @@ -21,6 +21,7 @@ Commands: get-pull-request-review get-review-comments [--cursor ] [--per-page ] get-pull-request-url + get-git-remote mark-pull-request-ready-for-review mark-pull-request-as-draft get-issue @@ -129,6 +130,7 @@ from scm.types import ( GetFileContentProtocol, GetFileUrlProtocol, GetGitRefProtocol, + GetGitRemoteProtocol, GetIssueCommentsProtocol, GetIssueProtocol, GetPullRequestCommentsProtocol, @@ -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") @@ -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) diff --git a/bin/gitlab-client b/bin/gitlab-client index d9fcb90..4a5a52f 100755 --- a/bin/gitlab-client +++ b/bin/gitlab-client @@ -23,6 +23,7 @@ Commands: get-pull-request-diff get-pull-request-review-threads [--include-reactions] get-pull-request-url + get-git-remote get-issue create-issue <body> [--assignee <user_id> ...] [--label <name> ...] update-issue <issue_id> [--state open|closed] [--assignee <user_id> ...] [--label <name> ...] @@ -117,6 +118,7 @@ from scm.types import ( GetDirectoryContentsProtocol, GetFileContentProtocol, GetFileUrlProtocol, + GetGitRemoteProtocol, GetIssueCommentsProtocol, GetIssueProtocol, GetPullRequestCommentsProtocol, @@ -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") @@ -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)) diff --git a/src/scm/actions.py b/src/scm/actions.py index dfa2071..1ed9706 100644 --- a/src/scm/actions.py +++ b/src/scm/actions.py @@ -78,6 +78,7 @@ GetFullTreeProtocol, GetGitCommitProtocol, GetGitRefProtocol, + GetGitRemoteProtocol, GetIssueCommentReactionsProtocol, GetIssueCommentsProtocol, GetIssueProtocol, @@ -106,6 +107,7 @@ GitBlob, GitCommitObject, GitRef, + GitRemote, GitRepository, GitTree, InputTreeEntry, @@ -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) @@ -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", diff --git a/src/scm/providers/cursor_origin/provider.py b/src/scm/providers/cursor_origin/provider.py index 6606f2c..0e3c569 100644 --- a/src/scm/providers/cursor_origin/provider.py +++ b/src/scm/providers/cursor_origin/provider.py @@ -48,6 +48,7 @@ GitCommitObject, GitCommitTree, GitRef, + GitRemote, GitRepository, GitTree, MoveCommitAction, @@ -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" @@ -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, diff --git a/src/scm/providers/github/provider.py b/src/scm/providers/github/provider.py index 64b7952..b359174 100644 --- a/src/scm/providers/github/provider.py +++ b/src/scm/providers/github/provider.py @@ -54,6 +54,7 @@ GitCommitObject, GitCommitTree, GitRef, + GitRemote, GitRepository, GitTree, InputTreeEntry, @@ -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, @@ -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", diff --git a/src/scm/providers/gitlab/provider.py b/src/scm/providers/gitlab/provider.py index 7f7e62c..d1e592c 100644 --- a/src/scm/providers/gitlab/provider.py +++ b/src/scm/providers/gitlab/provider.py @@ -50,6 +50,7 @@ GitCommitObject, GitCommitTree, GitRef, + GitRemote, GitRepository, GitTree, Issue, @@ -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: @@ -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, diff --git a/src/scm/test_fixtures.py b/src/scm/test_fixtures.py index ad6c0bc..5050004 100644 --- a/src/scm/test_fixtures.py +++ b/src/scm/test_fixtures.py @@ -28,6 +28,7 @@ GitCommitObject, GitCommitTree, GitRef, + GitRemote, GitTree, InputTreeEntry, Issue, @@ -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]: diff --git a/src/scm/types.py b/src/scm/types.py index 5a17f0b..a4e789e 100644 --- a/src/scm/types.py +++ b/src/scm/types.py @@ -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. @@ -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 @@ -1675,6 +1689,7 @@ def update_and_collapse_pull_request_comment( GetFileUrlProtocol, GetGitCommitProtocol, GetGitRefProtocol, + GetGitRemoteProtocol, GetIssueCommentReactionsProtocol, GetIssueCommentsProtocol, GetIssueProtocol, diff --git a/tests/unit/provider/test_cursor_origin.py b/tests/unit/provider/test_cursor_origin.py index a317a74..9da8184 100644 --- a/tests/unit/provider/test_cursor_origin.py +++ b/tests/unit/provider/test_cursor_origin.py @@ -34,6 +34,7 @@ ChmodCommitAction, CommitAuthorParam, DeleteCommitAction, + GitRemote, MoveCommitAction, Repository, WriteCommitAction, @@ -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} diff --git a/tests/unit/provider/test_github.py b/tests/unit/provider/test_github.py index 299797d..738722f 100644 --- a/tests/unit/provider/test_github.py +++ b/tests/unit/provider/test_github.py @@ -74,6 +74,7 @@ CredentialsSet, DeleteCommitAction, DiffLine, + GitRemote, MoveCommitAction, Repository, WriteCommitAction, @@ -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), @@ -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", diff --git a/tests/unit/provider/test_gitlab.py b/tests/unit/provider/test_gitlab.py index 35c560c..711c002 100644 --- a/tests/unit/provider/test_gitlab.py +++ b/tests/unit/provider/test_gitlab.py @@ -37,6 +37,7 @@ ChmodCommitAction, DeleteCommitAction, DiffLine, + GitRemote, MoveCommitAction, Repository, WriteCommitAction, @@ -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) == ( diff --git a/tests/unit/test_actions.py b/tests/unit/test_actions.py index 19706e5..51b4729 100644 --- a/tests/unit/test_actions.py +++ b/tests/unit/test_actions.py @@ -53,6 +53,7 @@ get_full_tree, get_git_commit, get_git_ref, + get_git_remote, get_issue, get_issue_comment_reactions, get_issue_comments, @@ -194,6 +195,7 @@ def fetch_repository(oid, rid) -> Repository: (get_commit_url, {"commit_sha": "abc123"}), (get_commits_url, {"commit_sha": "abc123"}), (get_pull_request_url, {"pull_request_id": "1"}), + (get_git_remote, {}), # Git blob operations (create_git_blob, {"content": "hello", "encoding": "utf-8"}), # File content operations @@ -475,6 +477,10 @@ def _check_get_pull_request_url(result: Any) -> None: assert result == "https://github.com/test-org/test-repo/pull/1" +def _check_get_git_remote(result: Any) -> None: + assert result == {"url": "https://github.com/test-org/test-repo.git", "basic_auth_username": "x-access-token"} + + def _check_create_git_blob(result: Any) -> None: assert result["data"]["sha"] == "blob123abc" assert result["type"] == "github" @@ -831,6 +837,7 @@ def _check_download_archive(result: Any) -> None: (get_commit_url, {"commit_sha": "abc123"}, _check_get_commit_url), (get_commits_url, {"commit_sha": "abc123"}, _check_get_commits_url), (get_pull_request_url, {"pull_request_id": "1"}, _check_get_pull_request_url), + (get_git_remote, {}, _check_get_git_remote), ( create_git_blob, {"content": "hello", "encoding": "utf-8"}, diff --git a/tests/unit/test_rpc_integration.py b/tests/unit/test_rpc_integration.py index ec26670..d17c05b 100644 --- a/tests/unit/test_rpc_integration.py +++ b/tests/unit/test_rpc_integration.py @@ -965,6 +965,11 @@ def make_client_scm(organization_id, repository_id, server): lambda scm: actions.get_pull_request_url(scm, "42"), "https://github.com/org/repo/pull/42", ), + ( + "get_git_remote", + lambda scm: actions.get_git_remote(scm), + {"url": "https://github.com/org/repo.git", "basic_auth_username": "x-access-token"}, + ), ]