Team Ai
Apppublic

evalstate/diffusers-pr-api

sourceHugging Faceupdated 6mo agoView on Hugging Face
0likes
github_api.py310 linesDownload Raw Back to data
1from __future__ import annotations2 3import json4import time5import urllib.error6import urllib.parse7import urllib.request8from collections.abc import Callable, Iterator9from datetime import UTC, datetime10 11from slop_farmer.data.http import urlopen_with_retry12 13 14class PullRequestDiffTooLargeError(RuntimeError):15    """Raised when GitHub refuses to render a PR diff because it is too large."""16 17 18class GitHubApiRequestError(RuntimeError):19    """Raised when GitHub returns a non-recoverable HTTP response."""20 21    def __init__(self, status_code: int, path: str, detail: str):22        self.status_code = status_code23        self.path = path24        self.detail = detail25        super().__init__(f"GitHub API request failed: {status_code} {path} {detail}")26 27 28class GitHubClient:29    def __init__(30        self,31        token: str | None,32        per_page: int = 100,33        timeout: int = 180,34        max_retries: int = 5,35        log: Callable[[str], None] | None = None,36    ):37        self.token = token38        self.per_page = per_page39        self.timeout = timeout40        self.max_retries = max_retries41        self.log = log42        self.base_url = "https://api.github.com"43        self.request_count = 044        self.rate_limit_log_every = 2545        self._rate_limit_thresholds_logged: dict[str, set[int]] = {}46 47    @staticmethod48    def _header_int(headers: dict[str, str], name: str) -> int | None:49        value = headers.get(name)50        if value is None:51            return None52        try:53            return int(value)54        except ValueError:55            return None56 57    def _maybe_log_rate_limit(self, path: str, headers: dict[str, str]) -> None:58        self.request_count += 159        if not self.log:60            return61        limit = self._header_int(headers, "x-ratelimit-limit")62        remaining = self._header_int(headers, "x-ratelimit-remaining")63        used = self._header_int(headers, "x-ratelimit-used")64        reset_at = self._header_int(headers, "x-ratelimit-reset")65        resource = headers.get("x-ratelimit-resource", "?")66        if limit is None or remaining is None:67            return68        should_log = self.request_count == 1 or self.request_count % self.rate_limit_log_every == 069        thresholds = (1000, 500, 250, 100, 50, 25, 10, 5, 1, 0)70        logged = self._rate_limit_thresholds_logged.setdefault(resource, set())71        for threshold in thresholds:72            if remaining <= threshold and threshold not in logged:73                should_log = True74                logged.add(threshold)75        if not should_log:76            return77        reset_text = "?"78        if reset_at is not None:79            reset_text = datetime.fromtimestamp(reset_at, tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")80        used_text = "?" if used is None else str(used)81        self.log(82            f"GitHub rate limit: resource={resource} used={used_text} remaining={remaining}/{limit} "83            f"reset={reset_text} after {path} (request {self.request_count})"84        )85 86    def _request(87        self,88        path: str,89        params: dict[str, object] | None = None,90        accept: str = "application/vnd.github+json",91    ) -> tuple[object, dict[str, str]]:92        query = f"?{urllib.parse.urlencode(params)}" if params else ""93        url = f"{self.base_url}{path}{query}"94        request = urllib.request.Request(url)95        request.add_header("Accept", accept)96        request.add_header("X-GitHub-Api-Version", "2022-11-28")97        if self.token:98            request.add_header("Authorization", f"Bearer {self.token}")99        while True:100            try:101                with urlopen_with_retry(102                    request,103                    timeout=self.timeout,104                    max_retries=self.max_retries,105                    log=self.log,106                    label=path,107                ) as response:108                    payload = response.read().decode("utf-8")109                    headers = {k.lower(): v for k, v in response.headers.items()}110                    self._maybe_log_rate_limit(path, headers)111                    return json.loads(payload), headers112            except urllib.error.HTTPError as exc:113                if exc.code == 403 and exc.headers.get("X-RateLimit-Remaining") == "0":114                    reset_at = int(exc.headers.get("X-RateLimit-Reset", "0") or "0")115                    sleep_for = max(reset_at - int(time.time()), 1)116                    if self.log:117                        self.log(118                            f"GitHub rate limit reached for {path}; sleeping {sleep_for}s until reset"119                        )120                    time.sleep(sleep_for)121                    continue122                detail = exc.read().decode("utf-8", errors="replace")123                if (124                    exc.code == 406125                    and '"field":"diff"' in detail126                    and '"code":"too_large"' in detail127                ):128                    raise PullRequestDiffTooLargeError(129                        f"GitHub diff too large: {path} {detail}"130                    ) from exc131                raise GitHubApiRequestError(exc.code, path, detail) from exc132 133    def _request_text(134        self,135        path: str,136        params: dict[str, object] | None = None,137        accept: str = "application/vnd.github.diff",138    ) -> tuple[str, dict[str, str]]:139        query = f"?{urllib.parse.urlencode(params)}" if params else ""140        url = f"{self.base_url}{path}{query}"141        request = urllib.request.Request(url)142        request.add_header("Accept", accept)143        request.add_header("X-GitHub-Api-Version", "2022-11-28")144        if self.token:145            request.add_header("Authorization", f"Bearer {self.token}")146        while True:147            try:148                with urlopen_with_retry(149                    request,150                    timeout=self.timeout,151                    max_retries=self.max_retries,152                    log=self.log,153                    label=path,154                ) as response:155                    payload = response.read().decode("utf-8", errors="replace")156                    headers = {k.lower(): v for k, v in response.headers.items()}157                    self._maybe_log_rate_limit(path, headers)158                    return payload, headers159            except urllib.error.HTTPError as exc:160                if exc.code == 403 and exc.headers.get("X-RateLimit-Remaining") == "0":161                    reset_at = int(exc.headers.get("X-RateLimit-Reset", "0") or "0")162                    sleep_for = max(reset_at - int(time.time()), 1)163                    if self.log:164                        self.log(165                            f"GitHub rate limit reached for {path}; sleeping {sleep_for}s until reset"166                        )167                    time.sleep(sleep_for)168                    continue169                detail = exc.read().decode("utf-8", errors="replace")170                if (171                    exc.code == 406172                    and '"field":"diff"' in detail173                    and '"code":"too_large"' in detail174                ):175                    raise PullRequestDiffTooLargeError(176                        f"GitHub diff too large: {path} {detail}"177                    ) from exc178                raise GitHubApiRequestError(exc.code, path, detail) from exc179 180    def paginate(181        self,182        path: str,183        params: dict[str, object] | None = None,184        accept: str = "application/vnd.github+json",185        limit: int | None = None,186    ) -> Iterator[dict]:187        page = 1188        yielded = 0189        params = dict(params or {})190        params["per_page"] = self.per_page191        while True:192            params["page"] = page193            payload, _headers = self._request(path, params=params, accept=accept)194            if not isinstance(payload, list):195                raise RuntimeError(f"Expected list payload from {path}, got {type(payload)!r}")196            if not payload:197                break198            for item in payload:199                if not isinstance(item, dict):200                    continue201                yield item202                yielded += 1203                if limit is not None and yielded >= limit:204                    return205            if len(payload) < self.per_page:206                break207            page += 1208 209    def get_json(self, path: str, accept: str = "application/vnd.github+json") -> dict:210        payload, _headers = self._request(path, accept=accept)211        if not isinstance(payload, dict):212            raise RuntimeError(f"Expected dict payload from {path}, got {type(payload)!r}")213        return payload214 215    def iter_repo_issues(216        self, owner: str, repo: str, since: str | None, limit: int | None217    ) -> Iterator[dict]:218        direction = "asc"219        if since is None and limit is not None:220            # When the caller asks for a bounded first pass without a watermark,221            # prefer the most recently updated items. Using ascending order here222            # causes small smoke tests to read the stalest issue/PR stubs first,223            # which can make recent-age filters appear to return an empty repo.224            direction = "desc"225        params: dict[str, object] = {"state": "all", "sort": "updated", "direction": direction}226        if since:227            params["since"] = since228        yield from self.paginate(f"/repos/{owner}/{repo}/issues", params=params, limit=limit)229 230    def iter_issue_comments(231        self, owner: str, repo: str, since: str | None, limit: int | None232    ) -> Iterator[dict]:233        params: dict[str, object] = {"sort": "updated", "direction": "asc"}234        if since:235            params["since"] = since236        yield from self.paginate(237            f"/repos/{owner}/{repo}/issues/comments", params=params, limit=limit238        )239 240    def iter_issue_comments_for_number(241        self,242        owner: str,243        repo: str,244        number: int,245        since: str | None,246        limit: int | None = None,247    ) -> Iterator[dict]:248        params: dict[str, object] = {"sort": "updated", "direction": "asc"}249        if since:250            params["since"] = since251        yield from self.paginate(252            f"/repos/{owner}/{repo}/issues/{number}/comments", params=params, limit=limit253        )254 255    def get_pull_request(self, owner: str, repo: str, number: int) -> dict:256        return self.get_json(f"/repos/{owner}/{repo}/pulls/{number}")257 258    def iter_pull_reviews(259        self, owner: str, repo: str, number: int, limit: int | None = None260    ) -> Iterator[dict]:261        yield from self.paginate(f"/repos/{owner}/{repo}/pulls/{number}/reviews", limit=limit)262 263    def iter_pull_review_comments(264        self, owner: str, repo: str, number: int, limit: int | None = None265    ) -> Iterator[dict]:266        yield from self.paginate(267            f"/repos/{owner}/{repo}/pulls/{number}/comments",268            params={"sort": "updated", "direction": "asc"},269            limit=limit,270        )271 272    def iter_pull_files(273        self, owner: str, repo: str, number: int, limit: int | None = None274    ) -> Iterator[dict]:275        yield from self.paginate(f"/repos/{owner}/{repo}/pulls/{number}/files", limit=limit)276 277    def get_pull_request_diff(self, owner: str, repo: str, number: int) -> str:278        path = f"/repos/{owner}/{repo}/pulls/{number}"279        try:280            payload, _headers = self._request_text(281                path,282                accept="application/vnd.github.diff",283            )284            return payload285        except PullRequestDiffTooLargeError:286            if self.log:287                self.log(288                    f"Skipping unified diff for pull request #{number}; GitHub reports diff too large"289                )290            return ""291 292    def iter_issue_timeline(293        self, owner: str, repo: str, number: int, limit: int | None = None294    ) -> Iterator[dict]:295        path = f"/repos/{owner}/{repo}/issues/{number}/timeline"296        try:297            yield from self.paginate(298                path,299                accept="application/vnd.github+json, application/vnd.github.mockingbird-preview+json",300                limit=limit,301            )302        except GitHubApiRequestError as exc:303            if exc.status_code < 500:304                raise305            if self.log:306                self.log(307                    f"Skipping timeline fetch for issue #{number} after GitHub {exc.status_code}: {path}"308                )309            return310