Files
2026-07-13 13:17:40 +08:00

213 lines
6.6 KiB
Python

"""
Minimal GitHub REST API client using requests, replacing PyGithub.
Supports the operations used by ray_release: reading/creating/updating issues,
reading labels, and reading pull requests.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
import requests
class GitHubException(Exception):
"""Raised when the GitHub API returns a non-2xx response."""
def __init__(self, status: int, data: Any = None, headers: Any = None) -> None:
self.status = status
self.data = data
self.headers = headers
super().__init__(f"GitHub API error {status}: {data}")
@dataclass
class GitHubLabel:
"""A GitHub label with a name."""
name: str
@dataclass
class GitHubPullUser:
"""The author of a pull request."""
login: str
@classmethod
def from_dict(cls, data: dict) -> "GitHubPullUser":
return cls(login=data["login"])
@dataclass
class GitHubPull:
"""A GitHub pull request."""
number: int
user: GitHubPullUser
@classmethod
def from_dict(cls, data: dict) -> "GitHubPull":
return cls(number=data["number"], user=GitHubPullUser.from_dict(data["user"]))
@dataclass
class GitHubIssue:
"""A GitHub issue."""
number: int
state: str
title: str
html_url: str
_repo: "GitHubRepo"
_labels: List[GitHubLabel] = field(default_factory=list)
def get_labels(self) -> List[GitHubLabel]:
"""Return the labels attached to this issue."""
return self._labels
def create_comment(self, body: str) -> None:
"""Post a comment on this issue."""
self._repo._client._post(
f"/repos/{self._repo.full_name}/issues/{self.number}/comments",
{"body": body},
)
def edit(
self,
title: Optional[str] = None,
state: Optional[str] = None,
labels: Optional[List[str]] = None,
) -> None:
"""Update the issue title, state, and/or labels."""
data: Dict[str, Any] = {}
if title is not None:
data["title"] = title
if state is not None:
data["state"] = state
if labels is not None:
data["labels"] = labels
resp = self._repo._client._patch(
f"/repos/{self._repo.full_name}/issues/{self.number}",
data,
)
updated = self._repo._issue_from_data(resp)
self.title = updated.title
self.state = updated.state
self._labels = updated._labels
@dataclass
class GitHubRepo:
"""A GitHub repository."""
full_name: str
_client: "GitHubClient"
def get_label(self, name: str) -> GitHubLabel:
"""Return a label object for the given name."""
return GitHubLabel(name=name)
def create_issue(
self,
title: str,
body: str = "",
labels: Optional[List[str]] = None,
) -> "GitHubIssue":
"""Create a new issue and return it."""
data: Dict[str, Any] = {"title": title, "body": body}
if labels is not None:
data["labels"] = labels
resp = self._client._post(f"/repos/{self.full_name}/issues", data)
return self._issue_from_data(resp)
def get_issues(
self,
state: str = "open",
labels: Optional[List[GitHubLabel]] = None,
) -> List[GitHubIssue]:
"""Return all issues matching the given state and labels."""
params: Dict[str, Any] = {"state": state}
if labels:
params["labels"] = ",".join(label.name for label in labels)
data = self._client._get_paginated(f"/repos/{self.full_name}/issues", params)
return [self._issue_from_data(item) for item in data]
def get_issue(self, number: int) -> GitHubIssue:
"""Return a single issue by number."""
data = self._client._get(f"/repos/{self.full_name}/issues/{number}")
return self._issue_from_data(data)
def get_pull(self, number: int) -> GitHubPull:
"""Return a single pull request by number."""
data = self._client._get(f"/repos/{self.full_name}/pulls/{number}")
return GitHubPull.from_dict(data)
def _issue_from_data(self, data: dict) -> GitHubIssue:
return GitHubIssue(
number=data["number"],
state=data["state"],
title=data["title"],
html_url=data["html_url"],
_repo=self,
_labels=[GitHubLabel(name=lbl["name"]) for lbl in data.get("labels", [])],
)
class GitHubClient:
"""Authenticated client for the GitHub REST API."""
BASE_URL = "https://api.github.com"
def __init__(self, token: str) -> None:
"""Initialize the client with a personal access token or app token."""
self._session = requests.Session()
self._session.headers.update(
{
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
)
def get_repo(self, full_name: str) -> "GitHubRepo":
"""Return a repo handle for owner/name (e.g. 'ray-project/ray')."""
return GitHubRepo(full_name=full_name, _client=self)
def _raise_for_response(self, resp: requests.Response) -> None:
if not resp.ok:
try:
data = resp.json()
except requests.exceptions.JSONDecodeError:
data = resp.text
raise GitHubException(resp.status_code, data, resp.headers)
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> dict:
resp = self._session.get(f"{self.BASE_URL}{path}", params=params)
self._raise_for_response(resp)
return resp.json()
def _get_paginated(
self, path: str, params: Optional[Dict[str, Any]] = None
) -> list:
params = dict(params or {})
params["per_page"] = 100
results = []
url: Optional[str] = f"{self.BASE_URL}{path}"
while url:
resp = self._session.get(url, params=params)
self._raise_for_response(resp)
results.extend(resp.json())
url = resp.links.get("next", {}).get("url")
params = None # subsequent URLs from Link header already include all params
return results
def _post(self, path: str, data: dict) -> dict:
resp = self._session.post(f"{self.BASE_URL}{path}", json=data)
self._raise_for_response(resp)
return resp.json()
def _patch(self, path: str, data: dict) -> dict:
resp = self._session.patch(f"{self.BASE_URL}{path}", json=data)
self._raise_for_response(resp)
return resp.json()