diff --git a/pr_split/git_ops/prs.py b/pr_split/git_ops/prs.py index f784173..f44bb44 100644 --- a/pr_split/git_ops/prs.py +++ b/pr_split/git_ops/prs.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import re import subprocess from collections.abc import Sequence @@ -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: @@ -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[^.:/@][^:/@]*)" + r"|[^@/:]+@(?P[^.:/@][^:/@]*)" + r"|(?P[^.:/@][^:/@]*\.[^:/@]+)" + 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 diff --git a/tests/test_git_prs.py b/tests/test_git_prs.py index 2043ef9..5a28463 100644 --- a/tests/test_git_prs.py +++ b/tests/test_git_prs.py @@ -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