import subprocess import sys import time from typing import Dict, List, Optional, Sequence, Tuple from urllib.parse import quote from model import ( Artifact, ArtifactPage, CreatedIssue, decode_json, JsonValue, PullRequest, RemoteComment, RemoteIssue, Workflow, WorkflowRun, WorkflowRuns, ) Fields = Sequence[Tuple[str, str]] ISSUES_QUERY = """query($owner:String!,$name:String!,$cursor:String){repository(owner:$owner,name:$name){issues(first:100,after:$cursor,orderBy:{field:CREATED_AT,direction:DESC}){nodes{number state body closedAt labels(first:100){nodes{name}}}pageInfo{hasNextPage endCursor}}}}""" class GitHubError(Exception): pass def _mapping(value: JsonValue) -> Dict[str, JsonValue]: if not isinstance(value, dict): raise GitHubError("invalid GitHub API response shape") return value def _sequence(value: JsonValue) -> List[JsonValue]: if not isinstance(value, list): raise GitHubError("invalid GitHub API response shape") return value def _string(value: JsonValue, default: Optional[str] = None) -> Optional[str]: if isinstance(value, str): return value if value is None: return default raise GitHubError("invalid GitHub API string") def _required_string(value: JsonValue) -> str: result = _string(value) if result is None: raise GitHubError("missing GitHub API string") return result def _integer(value: JsonValue) -> int: if isinstance(value, bool) or not isinstance(value, int): raise GitHubError("invalid GitHub API integer") return value def _boolean(value: JsonValue) -> bool: if not isinstance(value, bool): raise GitHubError("invalid GitHub API boolean") return value def _workflow_run(value: JsonValue) -> WorkflowRun: item = _mapping(value) pull_requests: List[PullRequest] = [] for raw_pull_request in _sequence(item.get("pull_requests", [])): pull_request = _mapping(raw_pull_request) base = _mapping(pull_request.get("base", {})) pull_requests.append(PullRequest(_string(base.get("ref")))) return WorkflowRun( id=_integer(item.get("id")), status=_required_string(item.get("status")), run_attempt=_integer(item.get("run_attempt")), event=_required_string(item.get("event")), head_branch=_string(item.get("head_branch")), pull_requests=tuple(pull_requests), updated_at=_required_string(item.get("updated_at")), run_started_at=_required_string(item.get("run_started_at")), html_url=_required_string(item.get("html_url")), name=_required_string(item.get("name")), path=_string(item.get("path")), ) def _remote_issue(value: JsonValue) -> RemoteIssue: item = _mapping(value) labels = tuple(_required_string(_mapping(label).get("name")) for label in _sequence(item.get("labels", []))) return RemoteIssue(_integer(item.get("number")), _required_string(item.get("state")), _string(item.get("body")), _string(item.get("closed_at") if "closed_at" in item else item.get("closedAt")), labels, item.get("pull_request") is not None) class GitHubClient: repository: str def __init__(self, repository: str) -> None: self.repository = repository def _command(self, endpoint: str, method: str, fields: Optional[Fields]) -> List[str]: command = ["gh", "api", "--hostname", "github.com", endpoint] if method != "GET" or fields: command.extend(("--method", method)) for key, value in fields or (): command.extend(("-f", "%s=%s" % (key, value))) return command def _call_bytes(self, endpoint: str) -> bytes: return self._execute(self._command(endpoint, "GET", None)) def _call_json(self, endpoint: str, method: str = "GET", fields: Optional[Fields] = None) -> JsonValue: output = self._execute(self._command(endpoint, method, fields)) try: value = decode_json(output.decode("utf-8")) except (UnicodeDecodeError, ValueError) as error: raise GitHubError("invalid GitHub API response") from error return value @staticmethod def _execute(command: Sequence[str]) -> bytes: endpoint = command[4] if len(command) > 4 else "unknown endpoint" started = time.monotonic() print("GitHub request started: %s" % endpoint, file=sys.stderr, flush=True) try: process = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) except OSError as error: elapsed = time.monotonic() - started print("GitHub request failed to start after %.1fs: %s" % (elapsed, endpoint), file=sys.stderr, flush=True) raise GitHubError("unable to invoke GitHub CLI") from error if process.returncode: elapsed = time.monotonic() - started stderr_lines = process.stderr.decode("utf-8", "replace").strip().splitlines() detail = ": %s" % stderr_lines[0][:200] if stderr_lines else "" print("GitHub request failed after %.1fs: %s%s" % (elapsed, endpoint, detail), file=sys.stderr, flush=True) raise GitHubError("GitHub API request failed%s" % detail) elapsed = time.monotonic() - started print("GitHub request completed in %.1fs: %s" % (elapsed, endpoint), file=sys.stderr, flush=True) return process.stdout def workflow(self, path: str) -> Workflow: item = _mapping(self._call_json("repos/%s/actions/workflows/%s" % (self.repository, quote(path, safe="")))) return Workflow(_integer(item.get("id"))) def workflow_runs(self, workflow_id: int, start: str, end: str) -> WorkflowRuns: endpoint = "repos/%s/actions/workflows/%s/runs" % (self.repository, workflow_id) fields = (("created", "%s..%s" % (start, end)), ("per_page", "100")) first = _mapping(self._call_json(endpoint, fields=fields + (("page", "1"),))) total = _integer(first.get("total_count")) runs = [_workflow_run(item) for item in _sequence(first.get("workflow_runs", []))] if total >= 1000: return WorkflowRuns(total, tuple(runs)) page = 2 while len(runs) < total and len(runs) < 1000: response = _mapping(self._call_json(endpoint, fields=fields + (("page", str(page)),))) items = _sequence(response.get("workflow_runs", [])) if not items: break runs.extend(_workflow_run(item) for item in items) page += 1 return WorkflowRuns(total, tuple(runs)) def run(self, run_id: int) -> WorkflowRun: return _workflow_run(self._call_json("repos/%s/actions/runs/%s" % (self.repository, run_id))) def attempt_started_at(self, run_id: int, attempt: int) -> str: item = _mapping(self._call_json("repos/%s/actions/runs/%s/attempts/%s" % (self.repository, run_id, attempt))) return _required_string(item.get("run_started_at")) def artifacts(self, run_id: int) -> ArtifactPage: endpoint = "repos/%s/actions/runs/%s/artifacts" % (self.repository, run_id) values, total = self._paged_object(endpoint, "artifacts") artifacts: List[Artifact] = [] for value in values: item = _mapping(value) artifacts.append(Artifact(_integer(item.get("id")), _required_string(item.get("name")), _boolean(item.get("expired")))) return ArtifactPage(total, tuple(artifacts)) def download_artifact(self, artifact_id: int) -> bytes: return self._call_bytes("repos/%s/actions/artifacts/%s/zip" % (self.repository, artifact_id)) def issues(self) -> Tuple[RemoteIssue, ...]: issues: List[RemoteIssue] = [] owner, separator, name = self.repository.partition("/") if not separator or not owner or not name: raise GitHubError("invalid GitHub repository") cursor: Optional[str] = None while True: fields: Tuple[Tuple[str, str], ...] = (("query", ISSUES_QUERY), ("owner", owner), ("name", name)) if cursor is not None: fields += (("cursor", cursor),) response = _mapping(self._call_json("graphql", "POST", fields)) data = _mapping(response.get("data")) repository = _mapping(data.get("repository")) connection = _mapping(repository.get("issues")) for value in _sequence(connection.get("nodes", [])): item = _mapping(value) labels = _mapping(item.get("labels")) label_names = tuple(_required_string(_mapping(label).get("name")) for label in _sequence(labels.get("nodes", []))) issues.append(RemoteIssue(_integer(item.get("number")), _required_string(item.get("state")).lower(), _string(item.get("body")), _string(item.get("closedAt")), label_names, False)) page_info = _mapping(connection.get("pageInfo")) if not _boolean(page_info.get("hasNextPage")): return tuple(issues) cursor = _required_string(page_info.get("endCursor")) def search_issue(self, key: str) -> Optional[int]: query = 'repo:%s is:issue in:body "%s"' % (self.repository, key) response = _mapping(self._call_json("search/issues", fields=(("q", query), ("per_page", "2")))) if _integer(response.get("total_count")) != 1: return None items = _sequence(response.get("items", [])) return _integer(_mapping(items[0]).get("number")) if len(items) == 1 else None def issue(self, issue_number: int) -> RemoteIssue: return _remote_issue(self._call_json("repos/%s/issues/%s" % (self.repository, issue_number))) def comments(self, issue_number: int) -> Tuple[RemoteComment, ...]: values = self._paged_list("repos/%s/issues/%s/comments" % (self.repository, issue_number)) return tuple(RemoteComment(_string(_mapping(value).get("body"))) for value in values) def _paged_list(self, endpoint: str, fields: Optional[Fields] = None) -> List[JsonValue]: result: List[JsonValue] = [] page = 1 while True: query = tuple(fields or ()) + (("per_page", "100"), ("page", str(page))) items = _sequence(self._call_json(endpoint, fields=query)) result.extend(items) if len(items) < 100: return result page += 1 def _paged_object(self, endpoint: str, key: str) -> Tuple[List[JsonValue], int]: result: List[JsonValue] = [] total = 0 page = 1 while True: response = _mapping(self._call_json(endpoint, fields=(("per_page", "100"), ("page", str(page))))) items = _sequence(response.get(key, [])) total_value = response.get("total_count") if total_value is not None: total = _integer(total_value) result.extend(items) if len(items) < 100: return result, total page += 1 def create_issue(self, title: str, body: str, labels: Sequence[str]) -> CreatedIssue: fields = [("title", title), ("body", body)] fields.extend(("labels[]", label) for label in labels) item = _mapping(self._call_json("repos/%s/issues" % self.repository, "POST", fields)) return CreatedIssue(_integer(item.get("number"))) def comment(self, issue_number: int, body: str) -> None: _ = self._call_json("repos/%s/issues/%s/comments" % (self.repository, issue_number), "POST", (("body", body),)) def reopen(self, issue_number: int) -> None: _ = self._call_json("repos/%s/issues/%s" % (self.repository, issue_number), "PATCH", (("state", "open"),)) def verify_labels(self, labels: Sequence[str]) -> None: for label in labels: _ = self._call_json("repos/%s/labels/%s" % (self.repository, quote(label, safe="")))