chore: import upstream snapshot with attribution
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user