mirror of
https://github.com/Sea-Haven-Industries/pr-reviewer.git
synced 2026-09-30 15:03:17 +00:00
Group the review queue into a collapsible section per repo (collapse state persisted in localStorage), with PRs ordered oldest to newest by creation date, so a large multi-repo queue is easier to scan. Add an optional per-PR auto-merge control: a method choice (squash by default per handbook, merge, or rebase) enables GitHub auto-merge via a GraphQL mutation, so the PR merges once required checks pass. It only fires when clicked; nothing merges automatically. Back this with created_at and node_id from the PR search, two new nullable store columns added via an idempotent PRAGMA-guarded migration, and a cheap-gate metadata backfill so already-cached PRs gain node_id without being re-reviewed. New endpoint POST /api/automerge.
168 lines
5.7 KiB
Python
168 lines
5.7 KiB
Python
"""GitHub API client.
|
|
|
|
Auth resolution order:
|
|
1. GITHUB_TOKEN from config/env
|
|
2. `gh auth token` from the local gh CLI
|
|
|
|
Read paths: search PRs, fetch PR detail + diff.
|
|
Write path: submit a PR review (guarded by an explicit call from the API layer,
|
|
which is only reached after the user clicks Post in the dashboard).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from typing import Any
|
|
|
|
import httpx2
|
|
|
|
from .config import Config
|
|
|
|
API = "https://api.github.com"
|
|
GRAPHQL = "https://api.github.com/graphql"
|
|
|
|
_ENABLE_AUTOMERGE = """
|
|
mutation($pr: ID!, $method: PullRequestMergeMethod!) {
|
|
enablePullRequestAutoMerge(input: {pullRequestId: $pr, mergeMethod: $method}) {
|
|
pullRequest { number autoMergeRequest { enabledAt } }
|
|
}
|
|
}
|
|
"""
|
|
|
|
_MERGE_METHODS = {"SQUASH", "MERGE", "REBASE"}
|
|
|
|
|
|
class GitHubError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def _raise_for_status(r: httpx2.Response) -> None:
|
|
"""Turn a non-2xx GitHub response into a GitHubError with a readable
|
|
message, so the dashboard shows 'GitHub 401: Bad credentials' instead of
|
|
a bare 500."""
|
|
if r.status_code >= 400:
|
|
try:
|
|
msg = r.json().get("message", r.text)
|
|
except Exception:
|
|
msg = r.text
|
|
raise GitHubError(f"GitHub {r.status_code}: {msg}")
|
|
|
|
|
|
def _resolve_token(cfg: Config) -> str:
|
|
if cfg.GITHUB_TOKEN:
|
|
return cfg.GITHUB_TOKEN
|
|
try:
|
|
out = subprocess.run(
|
|
["gh", "auth", "token"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
if out.returncode == 0 and out.stdout.strip():
|
|
return out.stdout.strip()
|
|
except (FileNotFoundError, subprocess.SubprocessError):
|
|
pass
|
|
raise GitHubError(
|
|
"No GitHub token. Set GITHUB_TOKEN in .env or authenticate with `gh auth login`."
|
|
)
|
|
|
|
|
|
class GitHubClient:
|
|
def __init__(self, cfg: Config):
|
|
self.cfg = cfg
|
|
self._token = _resolve_token(cfg)
|
|
self._headers = {
|
|
"Authorization": f"Bearer {self._token}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
}
|
|
|
|
# --- identity ---
|
|
def whoami(self) -> str:
|
|
with httpx2.Client(timeout=20) as c:
|
|
r = c.get(f"{API}/user", headers=self._headers)
|
|
_raise_for_status(r)
|
|
return r.json()["login"]
|
|
|
|
# --- read: search the review queue ---
|
|
def search_prs(self) -> list[dict[str, Any]]:
|
|
params = {"q": self.cfg.PR_SEARCH_FILTER, "per_page": self.cfg.MAX_PRS}
|
|
with httpx2.Client(timeout=30) as c:
|
|
r = c.get(f"{API}/search/issues", headers=self._headers, params=params)
|
|
_raise_for_status(r)
|
|
items = r.json().get("items", [])
|
|
prs = []
|
|
for it in items:
|
|
# search/issues returns PRs with a pull_request key; parse owner/repo/number from url
|
|
repo_url = it["repository_url"] # .../repos/{owner}/{repo}
|
|
owner, repo = repo_url.split("/repos/")[1].split("/")
|
|
prs.append(
|
|
{
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"number": it["number"],
|
|
"title": it["title"],
|
|
"url": it["html_url"],
|
|
"author": (it.get("user") or {}).get("login", ""),
|
|
"updated_at": it["updated_at"],
|
|
"created_at": it.get("created_at", ""),
|
|
"node_id": it.get("node_id", ""),
|
|
"body": it.get("body") or "",
|
|
"draft": it.get("draft", False),
|
|
}
|
|
)
|
|
return prs
|
|
|
|
# --- read: PR diff ---
|
|
def pr_diff(self, owner: str, repo: str, number: int) -> str:
|
|
headers = dict(self._headers)
|
|
headers["Accept"] = "application/vnd.github.v3.diff"
|
|
with httpx2.Client(timeout=60) as c:
|
|
r = c.get(
|
|
f"{API}/repos/{owner}/{repo}/pulls/{number}",
|
|
headers=headers,
|
|
)
|
|
_raise_for_status(r)
|
|
return r.text
|
|
|
|
# --- write: submit a review (side-effectful; called only on user action) ---
|
|
def submit_review(
|
|
self,
|
|
owner: str,
|
|
repo: str,
|
|
number: int,
|
|
body: str,
|
|
event: str,
|
|
) -> dict[str, Any]:
|
|
event = event.upper()
|
|
if event not in {"COMMENT", "APPROVE", "REQUEST_CHANGES"}:
|
|
raise GitHubError(f"Invalid event: {event}")
|
|
payload = {"body": body, "event": event}
|
|
with httpx2.Client(timeout=30) as c:
|
|
r = c.post(
|
|
f"{API}/repos/{owner}/{repo}/pulls/{number}/reviews",
|
|
headers=self._headers,
|
|
json=payload,
|
|
)
|
|
_raise_for_status(r)
|
|
return r.json()
|
|
|
|
# --- write: enable auto-merge (GraphQL; side-effectful) ---
|
|
def enable_automerge(self, node_id: str, method: str) -> dict[str, Any]:
|
|
method = (method or "").upper()
|
|
if method not in _MERGE_METHODS:
|
|
raise GitHubError(f"Invalid merge method: {method}")
|
|
if not node_id:
|
|
raise GitHubError("Missing PR node id for auto-merge")
|
|
payload = {
|
|
"query": _ENABLE_AUTOMERGE,
|
|
"variables": {"pr": node_id, "method": method},
|
|
}
|
|
with httpx2.Client(timeout=30) as c:
|
|
r = c.post(GRAPHQL, headers=self._headers, json=payload)
|
|
_raise_for_status(r) # HTTP-level (401/403/5xx)
|
|
data = r.json()
|
|
if data.get("errors"): # GraphQL-level (e.g. auto-merge not allowed)
|
|
msg = "; ".join(e.get("message", "") for e in data["errors"])
|
|
raise GitHubError(msg or "GraphQL error enabling auto-merge")
|
|
return (data.get("data") or {}).get("enablePullRequestAutoMerge") or {}
|