4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
181 lines
6.5 KiB
Python
181 lines
6.5 KiB
Python
"""Small GitHub REST client used by GitHub-backed OpenSRE tools."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
from urllib import error, parse, request
|
|
|
|
JsonPayload = dict[str, Any] | list[Any]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GitHubApiError(RuntimeError):
|
|
"""Typed GitHub API failure with enough context for callers to report safely."""
|
|
|
|
message: str
|
|
status_code: int | None = None
|
|
path: str = ""
|
|
rate_limit_remaining: str | None = None
|
|
rate_limit_reset: str | None = None
|
|
|
|
def __str__(self) -> str:
|
|
if self.status_code is None:
|
|
return self.message
|
|
return f"GitHub API error {self.status_code}: {self.message}"
|
|
|
|
|
|
def resolve_github_token(github_token: str | None = None) -> str:
|
|
"""Resolve a GitHub token from explicit input or standard env vars."""
|
|
|
|
return (github_token or os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN") or "").strip()
|
|
|
|
|
|
def _next_link(headers: Any) -> str | None:
|
|
raw_link = ""
|
|
if hasattr(headers, "get"):
|
|
raw_link = str(headers.get("Link") or headers.get("link") or "")
|
|
for part in raw_link.split(","):
|
|
url_part, _, rel_part = part.partition(";")
|
|
if 'rel="next"' in rel_part or "rel=next" in rel_part:
|
|
return url_part.strip().strip("<>")
|
|
return None
|
|
|
|
|
|
def _decode_json_payload(raw: str, *, path: str) -> JsonPayload:
|
|
if not raw.strip():
|
|
return {}
|
|
try:
|
|
parsed = json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise GitHubApiError("GitHub API returned invalid JSON.", path=path) from exc
|
|
if isinstance(parsed, dict | list):
|
|
return parsed
|
|
return {"value": parsed}
|
|
|
|
|
|
class GitHubRestClient:
|
|
"""Minimal GitHub REST API client with pagination and typed errors."""
|
|
|
|
def __init__(
|
|
self, github_token: str | None = None, *, base_url: str = "https://api.github.com"
|
|
) -> None:
|
|
self._token = resolve_github_token(github_token)
|
|
self._base_url = base_url.rstrip("/")
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
params: dict[str, Any] | None = None,
|
|
body: dict[str, Any] | None = None,
|
|
) -> JsonPayload:
|
|
if not self._token:
|
|
raise GitHubApiError(
|
|
"GitHub token is required. Configure github_token, GITHUB_TOKEN, or GH_TOKEN."
|
|
)
|
|
|
|
url = self._url(path, params=params)
|
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
req = request.Request(
|
|
url,
|
|
data=data,
|
|
method=method.upper(),
|
|
headers={
|
|
"Accept": "application/vnd.github+json",
|
|
"Authorization": f"Bearer {self._token}",
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
)
|
|
try:
|
|
with request.urlopen(req, timeout=20) as response: # nosemgrep
|
|
raw = response.read().decode("utf-8")
|
|
except error.HTTPError as exc:
|
|
detail = ""
|
|
if exc.fp is not None:
|
|
detail = exc.read().decode("utf-8", errors="replace")
|
|
message = detail or exc.msg or "GitHub API request failed."
|
|
raise GitHubApiError(
|
|
message,
|
|
status_code=exc.code,
|
|
path=path,
|
|
rate_limit_remaining=exc.headers.get("X-RateLimit-Remaining")
|
|
if exc.headers
|
|
else None,
|
|
rate_limit_reset=exc.headers.get("X-RateLimit-Reset") if exc.headers else None,
|
|
) from exc
|
|
except error.URLError as exc:
|
|
raise GitHubApiError(f"GitHub API request failed: {exc.reason}", path=path) from exc
|
|
|
|
return _decode_json_payload(raw, path=path)
|
|
|
|
def paginate(
|
|
self,
|
|
path: str,
|
|
*,
|
|
params: dict[str, Any] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
if not self._token:
|
|
raise GitHubApiError(
|
|
"GitHub token is required. Configure github_token, GITHUB_TOKEN, or GH_TOKEN."
|
|
)
|
|
|
|
url: str | None = self._url(path, params=params)
|
|
items: list[dict[str, Any]] = []
|
|
while url:
|
|
req = request.Request(
|
|
url,
|
|
method="GET",
|
|
headers={
|
|
"Accept": "application/vnd.github+json",
|
|
"Authorization": f"Bearer {self._token}",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
)
|
|
try:
|
|
with request.urlopen(req, timeout=20) as response: # nosemgrep
|
|
raw = response.read().decode("utf-8")
|
|
headers = getattr(response, "headers", {})
|
|
except error.HTTPError as exc:
|
|
detail = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
|
|
raise GitHubApiError(
|
|
detail or exc.msg or "GitHub API request failed.",
|
|
status_code=exc.code,
|
|
path=path,
|
|
rate_limit_remaining=exc.headers.get("X-RateLimit-Remaining")
|
|
if exc.headers
|
|
else None,
|
|
rate_limit_reset=exc.headers.get("X-RateLimit-Reset") if exc.headers else None,
|
|
) from exc
|
|
except error.URLError as exc:
|
|
raise GitHubApiError(f"GitHub API request failed: {exc.reason}", path=path) from exc
|
|
|
|
parsed = _decode_json_payload(raw, path=path) if raw.strip() else []
|
|
if isinstance(parsed, list):
|
|
items.extend(item for item in parsed if isinstance(item, dict))
|
|
elif isinstance(parsed, dict):
|
|
# Search endpoints return objects with an items list.
|
|
raw_items = parsed.get("items")
|
|
if isinstance(raw_items, list):
|
|
items.extend(item for item in raw_items if isinstance(item, dict))
|
|
url = _next_link(headers)
|
|
return items
|
|
|
|
def _url(self, path: str, *, params: dict[str, Any] | None = None) -> str:
|
|
if path.startswith("http://") or path.startswith("https://"):
|
|
base = path
|
|
else:
|
|
base = f"{self._base_url}/{path.lstrip('/')}"
|
|
query = parse.urlencode(params, doseq=True) if params else ""
|
|
if not query:
|
|
return base
|
|
separator = "&" if "?" in base else "?"
|
|
return f"{base}{separator}{query}"
|
|
|
|
|
|
__all__ = ["GitHubApiError", "GitHubRestClient", "JsonPayload", "resolve_github_token"]
|