pr-reviewer/app/github_client.py
Adam Moussa 26f0558589
Group PRs by repo in the sidebar and add auto-merge
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.
2026-07-01 19:11:09 -04:00

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 {}