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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/contribcheck/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"),
)
],
)
Expand Down Expand Up @@ -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}"
),
)
],
)
Expand Down
16 changes: 13 additions & 3 deletions src/contribcheck/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down
30 changes: 27 additions & 3 deletions src/contribcheck/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
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

from contribcheck.exceptions import GitHubAPIError, GitHubRateLimitError
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}


Expand All @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -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("/"), "", ""))
3 changes: 2 additions & 1 deletion src/contribcheck/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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):
Expand Down
18 changes: 11 additions & 7 deletions src/contribcheck/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 20 additions & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
27 changes: 26 additions & 1 deletion tests/test_github.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"}))
Expand Down
11 changes: 10 additions & 1 deletion tests/test_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
],
)
Expand All @@ -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",
],
)
Expand Down
Loading