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
44 changes: 43 additions & 1 deletion pr_split/git_ops/prs.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import os
import re
import subprocess
from collections.abc import Sequence
Expand All @@ -11,6 +12,7 @@
from ..constants import FORK_REF_PREFIX, PR_REF_PREFIX
from ..exceptions import ErrorMsg, GitOperationError
from ..types_defs import ForkPRInfo
from .branches import run_git


def _run_gh(*args: str) -> str:
Expand All @@ -24,9 +26,49 @@ def _run_gh(*args: str) -> str:
return result.stdout.strip()


# Matches the host of an https/ssh/git URL or an scp-like ``user@host:path``.
# Local paths and file:// URLs deliberately do not match: they have no host
# for gh to authenticate against. A dot is required only in the bare
# ``host:path`` form, where it separates real hosts from Windows drives and
# relative paths; scheme and ``user@`` forms accept single-label hosts
# (``git@ghe:org/repo``), which gh itself supports.
_REMOTE_HOST_RE = re.compile(
r"^(?:"
r"(?:https?|ssh|git|git\+ssh|ssh\+git)://(?:[^@/]+@)?(?P<scheme_host>[^.:/@][^:/@]*)"
r"|[^@/:]+@(?P<scp_host>[^.:/@][^:/@]*)"
r"|(?P<bare_host>[^.:/@][^:/@]*\.[^:/@]+)"
r")(?::\d+)?[:/]"
)


def gh_host() -> str:
"""The GitHub host pr-split talks to, resolved the way gh does.

gh targets the host of the repository's remote and only treats GH_HOST
as an override, so an enterprise-only checkout must be checked against
its own host, not github.com.
"""
override = os.environ.get("GH_HOST")
if override:
return override.lower()
try:
remote = run_git("remote", "get-url", "origin")
except GitOperationError:
return "github.com"
match = _REMOTE_HOST_RE.match(remote.strip())
if not match:
return "github.com"
host = match.group("scheme_host") or match.group("scp_host") or match.group("bare_host")
# gh lowercases hosts it parses from remotes; `--hostname` is case-sensitive.
return host.lower()


def check_gh_auth() -> bool:
# Without --hostname, `gh auth status` exits 1 when *any* configured
# host is unauthenticated (a stale enterprise token), even though every
# call pr-split makes targets one host.
try:
_run_gh("auth", "status")
_run_gh("auth", "status", "--hostname", gh_host())
except GitOperationError:
return False
return True
Expand Down
66 changes: 66 additions & 0 deletions tests/test_git_prs.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,69 @@ def test_ready_by_default(self, mock_gh: MagicMock) -> None:
mock_gh.return_value = "https://github.com/org/repo/pull/7"
create_pr("head", "main", "Title", "Body")
assert "--draft" not in mock_gh.call_args.args


class TestCheckGhAuthHost:
@patch("pr_split.git_ops.prs.run_git", return_value="git@github.com:org/repo.git")
@patch("pr_split.git_ops.prs._run_gh", return_value="")
def test_checks_only_the_target_host(
self, mock_gh: MagicMock, mock_git: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
from pr_split.git_ops.prs import check_gh_auth

monkeypatch.delenv("GH_HOST", raising=False)
assert check_gh_auth() is True
mock_gh.assert_called_once_with("auth", "status", "--hostname", "github.com")

@pytest.mark.parametrize(
("remote", "host"),
[
("https://ghe.example.com/org/repo.git", "ghe.example.com"),
("ssh://git@ghe.example.com:2222/org/repo.git", "ghe.example.com"),
("git@ghe.example.com:org/repo.git", "ghe.example.com"),
("https://user:tok@ghe.example.com/org/repo", "ghe.example.com"),
("git@github.com:org/repo.git", "github.com"),
# gh lowercases remote hosts; `--hostname` is case-sensitive.
("https://GHE.Example.COM/org/repo.git", "ghe.example.com"),
# Local remotes have no host to authenticate against.
("../other", "github.com"),
("/srv/repo.git", "github.com"),
("file:///srv/repo.git", "github.com"),
# Single-label GHE hosts are valid in scheme and scp forms.
("git@ghe:org/repo.git", "ghe"),
("ssh://git@ghe/org/repo.git", "ghe"),
# Bare scp form still needs a dot to rule out local paths.
("ghe.example.com:org/repo.git", "ghe.example.com"),
],
)
def test_host_is_derived_from_the_origin_remote(
self, monkeypatch: pytest.MonkeyPatch, remote: str, host: str
) -> None:
from pr_split.git_ops.prs import gh_host

monkeypatch.delenv("GH_HOST", raising=False)
with patch("pr_split.git_ops.prs.run_git", return_value=remote):
assert gh_host() == host

def test_no_remote_falls_back_to_github(self, monkeypatch: pytest.MonkeyPatch) -> None:
from pr_split.git_ops.prs import gh_host

monkeypatch.delenv("GH_HOST", raising=False)
with patch("pr_split.git_ops.prs.run_git", side_effect=GitOperationError("no origin")):
assert gh_host() == "github.com"

@patch("pr_split.git_ops.prs._run_gh", return_value="")
def test_honours_gh_host(self, mock_gh: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
from pr_split.git_ops.prs import check_gh_auth

monkeypatch.setenv("GH_HOST", "GHE.example.com")
with patch("pr_split.git_ops.prs.run_git") as mock_git:
check_gh_auth()
mock_gh.assert_called_once_with("auth", "status", "--hostname", "ghe.example.com")
mock_git.assert_not_called()

@patch("pr_split.git_ops.prs._run_gh", side_effect=GitOperationError("not logged in"))
def test_unauthenticated_target_host_is_false(self, mock_gh: MagicMock) -> None:
from pr_split.git_ops.prs import check_gh_auth

assert check_gh_auth() is False
Loading