diff --git a/README.md b/README.md index 4b85771..1886648 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,18 @@ contribcheck inspect owner/repository#123 --fail-on caution Exit codes are `0` for a completed inspection, `1` for an operational error, and `2` when the configured readiness threshold is reached. +### GitHub Enterprise Server + +Set the API endpoint with `GITHUB_API_URL`, or override it for one command with `--base-url`: + +```bash +export GITHUB_API_URL="https://github.example.com/api/v3" +contribcheck inspect https://github.example.com/team/project/issues/123 +contribcheck inspect team/project#123 --base-url https://github.example.com/api/v3 +``` + +The precedence is CLI option, `GITHUB_API_URL`, then `https://api.github.com`. The endpoint must be HTTP or HTTPS and cannot contain credentials, query parameters, or fragments. Tokens are sent only to the configured API endpoint. + ## Python SDK ```python diff --git a/src/contribcheck/analyzer.py b/src/contribcheck/analyzer.py index d6368e7..febbc9b 100644 --- a/src/contribcheck/analyzer.py +++ b/src/contribcheck/analyzer.py @@ -186,7 +186,7 @@ def _base_branch_check( [ Evidence( text=branch, - url=_url(f"https://github.com/{target.full_name}/tree/{branch}"), + url=_url(f"https://{target.host}/{target.full_name}/tree/{branch}"), ) ], ) @@ -348,7 +348,9 @@ def _contribution_docs_check( [ Evidence( text=path, - url=_url(f"https://github.com/{target.full_name}/blob/{default_branch}/{path}"), + url=_url( + f"https://{target.host}/{target.full_name}/blob/{default_branch}/{path}" + ), ) ], ) diff --git a/src/contribcheck/cli.py b/src/contribcheck/cli.py index 226e5f5..f72ae75 100644 --- a/src/contribcheck/cli.py +++ b/src/contribcheck/cli.py @@ -52,6 +52,10 @@ def inspect( bool, typer.Option("--json", help="Print the complete report as JSON."), ] = False, + base_url: Annotated[ + str | None, + typer.Option(help="GitHub API endpoint; overrides GITHUB_API_URL."), + ] = None, fail_on: Annotated[ FailOn, typer.Option(help="Return a non-zero exit code at this readiness threshold."), @@ -60,7 +64,7 @@ def inspect( """Inspect one public GitHub issue.""" try: - report = asyncio.run(_inspect(reference, actor=actor, token=token)) + report = asyncio.run(_inspect(reference, actor=actor, token=token, base_url=base_url)) except (ContribCheckError, httpx.HTTPError, ValueError) as error: console.print(f"[bold red]Inspection failed:[/bold red] {error}", highlight=False) raise typer.Exit(code=1) from error @@ -113,8 +117,14 @@ def main( """ContribCheck CLI.""" -async def _inspect(reference: str, *, actor: str | None, token: str | None) -> InspectionReport: - async with GitHubClient(token=token) as client: +async def _inspect( + reference: str, + *, + actor: str | None, + token: str | None, + base_url: str | None = None, +) -> InspectionReport: + async with GitHubClient(token=token, base_url=base_url) as client: resolved_actor = actor or os.getenv("GITHUB_ACTOR") return await IssueAnalyzer(client).inspect(reference, actor=resolved_actor) diff --git a/src/contribcheck/github.py b/src/contribcheck/github.py index 170ac57..a9abceb 100644 --- a/src/contribcheck/github.py +++ b/src/contribcheck/github.py @@ -6,7 +6,7 @@ import os from collections.abc import Mapping from typing import Any, Self, cast -from urllib.parse import quote +from urllib.parse import quote, urlsplit, urlunsplit import httpx @@ -14,6 +14,7 @@ from contribcheck.models import IssueTarget, JsonObject API_VERSION = "2026-03-10" +DEFAULT_API_URL = "https://api.github.com" TRANSIENT_STATUSES = {429, 500, 502, 503, 504} @@ -24,12 +25,15 @@ def __init__( self, token: str | None = None, *, - base_url: str = "https://api.github.com", + base_url: str | None = None, timeout: float = 15.0, max_retries: int = 2, transport: httpx.AsyncBaseTransport | None = None, ) -> None: resolved_token = token if token is not None else os.getenv("GITHUB_TOKEN") + resolved_base_url = normalize_api_base_url( + base_url if base_url is not None else os.getenv("GITHUB_API_URL") + ) headers = { "Accept": "application/vnd.github+json", "User-Agent": "contribcheck/0.1.0", @@ -39,7 +43,7 @@ def __init__( headers["Authorization"] = f"Bearer {resolved_token}" self._client = httpx.AsyncClient( - base_url=base_url, + base_url=resolved_base_url, headers=headers, timeout=timeout, transport=transport, @@ -213,3 +217,23 @@ def _expect_object_list(value: Any) -> list[JsonObject]: if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): raise GitHubAPIError(502, "Expected a list of JSON objects", endpoint="response") return cast(list[JsonObject], value) + + +def normalize_api_base_url(value: str | None) -> str: + """Validate and normalize a GitHub API endpoint.""" + + candidate = (value or DEFAULT_API_URL).strip() + parsed = urlsplit(candidate) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + ): + raise ValueError( + "GitHub API URL must be an HTTP(S) URL without credentials, query parameters, " + "or fragments." + ) + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "")) diff --git a/src/contribcheck/models.py b/src/contribcheck/models.py index 937df66..408cb32 100644 --- a/src/contribcheck/models.py +++ b/src/contribcheck/models.py @@ -39,6 +39,7 @@ class IssueTarget(StrictModel): owner: str repository: str number: int = Field(gt=0) + host: str = "github.com" @property def full_name(self) -> str: @@ -50,7 +51,7 @@ def full_name(self) -> str: def url(self) -> str: """Return the canonical browser URL.""" - return f"https://github.com/{self.full_name}/issues/{self.number}" + return f"https://{self.host}/{self.full_name}/issues/{self.number}" class Evidence(StrictModel): diff --git a/src/contribcheck/parsing.py b/src/contribcheck/parsing.py index bb3e61d..0ecae7b 100644 --- a/src/contribcheck/parsing.py +++ b/src/contribcheck/parsing.py @@ -32,17 +32,21 @@ def parse_issue_reference(value: str) -> IssueTarget: ) parsed = urlparse(candidate) - if parsed.scheme not in {"http", "https"} or parsed.hostname not in { - "github.com", - "www.github.com", - }: - raise InvalidIssueURLError("Use a github.com issue URL or owner/repository#number.") + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise InvalidIssueURLError("Use an HTTP(S) issue URL or owner/repository#number.") + if parsed.username or parsed.password: + raise InvalidIssueURLError("Issue URLs must not contain embedded credentials.") parts = [part for part in parsed.path.split("/") if part] if len(parts) != 4 or parts[2] != "issues" or not parts[3].isdigit(): - raise InvalidIssueURLError("The URL must point to a GitHub issue, not a repository or PR.") + raise InvalidIssueURLError("The URL must point to an issue, not a repository or PR.") - return IssueTarget(owner=parts[0], repository=parts[1], number=int(parts[3])) + return IssueTarget( + owner=parts[0], + repository=parts[1], + number=int(parts[3]), + host=parsed.netloc.casefold(), + ) def extract_base_branch(body: str | None) -> str | None: diff --git a/tests/test_api.py b/tests/test_api.py index e637593..21ada48 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -53,7 +53,7 @@ async def test_inspect_endpoint_rejects_invalid_reference() -> None: response = await client.post("/v1/inspect", json={"url": "not-an-issue"}) assert response.status_code == 400 - assert "github.com issue URL" in response.json()["detail"] + assert "HTTP(S) issue URL" in response.json()["detail"] def test_bearer_token_parser_is_strict() -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 0682451..d4edd84 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -21,17 +21,30 @@ def _report(status: OverallStatus = OverallStatus.READY) -> InspectionReport: def test_inspect_json_output(monkeypatch: pytest.MonkeyPatch) -> None: async def fake_inspect( - reference: str, *, actor: str | None, token: str | None + reference: str, + *, + actor: str | None, + token: str | None, + base_url: str | None = None, ) -> InspectionReport: assert reference == "owner/repo#7" assert actor == "gokul-debugger" assert token is None + assert base_url == "https://github.example/api/v3" return _report() monkeypatch.setattr("contribcheck.cli._inspect", fake_inspect) result = runner.invoke( app, - ["inspect", "owner/repo#7", "--actor", "gokul-debugger", "--json"], + [ + "inspect", + "owner/repo#7", + "--actor", + "gokul-debugger", + "--json", + "--base-url", + "https://github.example/api/v3", + ], ) assert result.exit_code == 0 @@ -40,7 +53,11 @@ async def fake_inspect( def test_fail_on_blocked_returns_exit_code_two(monkeypatch: pytest.MonkeyPatch) -> None: async def fake_inspect( - reference: str, *, actor: str | None, token: str | None + reference: str, + *, + actor: str | None, + token: str | None, + base_url: str | None = None, ) -> InspectionReport: return _report(OverallStatus.BLOCKED) diff --git a/tests/test_github.py b/tests/test_github.py index b56eca6..69e557f 100644 --- a/tests/test_github.py +++ b/tests/test_github.py @@ -6,7 +6,7 @@ import pytest from contribcheck.exceptions import GitHubRateLimitError -from contribcheck.github import API_VERSION, GitHubClient +from contribcheck.github import API_VERSION, GitHubClient, normalize_api_base_url from contribcheck.models import IssueTarget TARGET = IssueTarget(owner="owner", repository="repo", number=7) @@ -25,6 +25,31 @@ async def handler(request: httpx.Request) -> httpx.Response: assert repository["default_branch"] == "main" +@pytest.mark.asyncio +async def test_explicit_api_url_overrides_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_API_URL", "https://env.example/api/v3") + + async def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url).startswith("https://explicit.example/api/v3/") + return httpx.Response(200, json={"default_branch": "main"}) + + async with GitHubClient( + base_url="https://explicit.example/api/v3/", + transport=httpx.MockTransport(handler), + ) as client: + await client.get_repository(TARGET) + + +def test_normalize_api_base_url_rejects_credentials_and_query() -> None: + assert ( + normalize_api_base_url("https://github.example/api/v3/") == "https://github.example/api/v3" + ) + with pytest.raises(ValueError): + normalize_api_base_url("https://user:secret@github.example/api/v3") + with pytest.raises(ValueError): + normalize_api_base_url("https://github.example/api/v3?token=secret") + + @pytest.mark.asyncio async def test_branch_exists_returns_false_for_404() -> None: transport = httpx.MockTransport(lambda _: httpx.Response(404, json={"message": "Not Found"})) diff --git a/tests/test_parsing.py b/tests/test_parsing.py index cc7ee2a..fd5cd7b 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -11,6 +11,7 @@ [ ("https://github.com/sigma67/ytmusicapi/issues/986", "sigma67", "ytmusicapi", 986), ("https://www.github.com/org/repo/issues/12?tab=comments", "org", "repo", 12), + ("https://git.example.com/org/repo/issues/12", "org", "repo", 12), ("gokul-debugger/contribcheck#1", "gokul-debugger", "contribcheck", 1), ], ) @@ -22,12 +23,20 @@ def test_parse_issue_reference(reference: str, owner: str, repository: str, numb assert target.number == number +def test_parse_issue_reference_preserves_enterprise_host() -> None: + target = parse_issue_reference("https://git.example.com/org/repo/issues/12") + + assert target.host == "git.example.com" + assert target.url == "https://git.example.com/org/repo/issues/12" + + @pytest.mark.parametrize( "reference", [ "https://github.com/owner/repo", "https://github.com/owner/repo/pull/1", - "https://example.com/owner/repo/issues/1", + "ftp://example.com/owner/repo/issues/1", + "https://user:secret@example.com/owner/repo/issues/1", "owner/repo#0", ], )