diff --git a/auto_merge_bot.py b/auto_merge_bot.py index f6edcf8..0c93f89 100644 --- a/auto_merge_bot.py +++ b/auto_merge_bot.py @@ -73,6 +73,23 @@ def get_pr_reviews(token: str, repo_full: str, pr_number: int) -> list: ) return r.json() +def post_discord_notification(repo_full: str, pr_number: int, status: str, summary: str = "", score: str = "", url: str = ""): + """Fire-and-forget Discord notification via the server's internal endpoint.""" + import httpx + notify_url = os.environ.get("PR_AGENT_NOTIFY_URL", "http://127.0.0.1:4002/api/v1/notify_review") + try: + with httpx.Client(timeout=5) as client: + client.post(notify_url, json={ + "repo": repo_full, + "pr": pr_number, + "status": status, + "summary": summary[:500], + "score": str(score), + "url": url, + }) + except Exception: + pass + def get_pr_comments(token: str, repo_full: str, pr_number: int) -> list: """Get issue comments for a PR""" import httpx @@ -216,6 +233,39 @@ def main(): comments = get_pr_comments(token, repo_full, pr_num) has_review, score = has_bot_comment_with_review(comments) + # ── TRIVIAL PR FAST-PATH ── + # Docs-only / version bumps / dependabot / tiny diffs with green + # CI skip the AI-fix + score gate and merge directly. + if has_review: + changed_files, total_lines = [], 0 + is_trivial = False + try: + from trivial_merge import ( + is_trivial_pr, get_pr_changed_files, check_ci_passed, + merge_pr as trivial_merge, + ) + changed_files, total_lines = get_pr_changed_files(token, repo_full, pr_num) + is_trivial = is_trivial_pr(pr_title, pr_author, changed_files, total_lines) + except Exception as e: + is_trivial = False + print(f" ⚠️ trivial check failed: {e}") + + if is_trivial: + print(f" ⚡ TRIVIAL PR ({total_lines} lines, {len(changed_files)} files). Fast-path approve+merge...") + ci_ok, ci_msg = check_ci_passed(token, repo_full, pr.get("head", {}).get("sha", "")) + if not ci_ok: + print(f" ⏳ CI not green: {ci_msg}") + continue + if approve_pr(token, repo_full, pr_num): + print(f" ✅ Approved (trivial)") + time.sleep(1) + success, msg = trivial_merge(token, repo_full, pr_num, pr.get("head", {}).get("sha", "")) + print(f" {'✅ Merged!' if success else '❌ ' + msg}") + post_discord_notification(repo_full, pr_num, "done" if success else "failed", + summary=f"Trivial PR auto-merged ({total_lines} lines)" if success else f"Trivial merge failed: {msg}", + score=score, url=pr.get("html_url", "")) + continue + if has_review and score >= 5: print(f" 📝 Review found (score: {score}). Approving + merging...") diff --git a/flake.nix b/flake.nix index 9de3b48..1c221b0 100644 --- a/flake.nix +++ b/flake.nix @@ -39,6 +39,10 @@ installPhase = '' mkdir -p $out/bin $out/lib/pr-agent-server cp run_server.py $out/lib/pr-agent-server/ + cp sync-key.py $out/lib/pr-agent-server/ + cp health-check.py $out/lib/pr-agent-server/ + cp trivial_merge.py $out/lib/pr-agent-server/ + cp auto_merge_bot.py $out/lib/pr-agent-server/ cat > $out/bin/pr-agent-server << WRAPPER #!${pkgs.runtimeShell} @@ -48,6 +52,20 @@ cd $out/lib/pr-agent-server exec $out/venv/bin/python run_server.py WRAPPER chmod +x $out/bin/pr-agent-server + + cat > $out/bin/pr-agent-sync-key << WRAPPER2 +#!${pkgs.runtimeShell} +export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib:\$LD_LIBRARY_PATH +exec $out/venv/bin/python $out/lib/pr-agent-server/sync-key.py +WRAPPER2 + chmod +x $out/bin/pr-agent-sync-key + + cat > $out/bin/pr-agent-health-check << WRAPPER3 +#!${pkgs.runtimeShell} +export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib:\$LD_LIBRARY_PATH +exec $out/venv/bin/python $out/lib/pr-agent-server/health-check.py +WRAPPER3 + chmod +x $out/bin/pr-agent-health-check ''; }; }); diff --git a/health-check.py b/health-check.py new file mode 100644 index 0000000..c21e218 --- /dev/null +++ b/health-check.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +PR-Agent Model Health Watchdog +=============================== +Runs every 10 minutes via Hermes no_agent cron. Tests the exact model config +the pr-agent server uses (primary + fallbacks) against 9router via litellm. + +Output contract (no_agent cron): + - OK → empty stdout (silent, $0 idle) + - FAIL → one-line alert + detail (delivered to Discord/home channel) +""" +import os, sys, json, hashlib, subprocess +from pathlib import Path + +BWS_SECRET_ID = "2aef2194-971d-4dae-99dd-b49a0041f97c" +ROUTER_BASE = "https://9router.asepharyana.my.id/v1" +PRIMARY = "openai/claude-opus-4-8" +FALLBACKS = ["openai/ATLAS", "openai/gemini", "openai/text", "openai/deepseek-v4-flash-free"] +CONSECUTIVE_FAIL_FILE = Path("/tmp/pr-agent-health-fail-count") + +# ── key from BWS ──────────────────────────────────────────────────────────── +def get_key() -> str: + token = os.environ.get("BWS_ACCESS_TOKEN", "") + if not token and Path("/etc/bws-token").is_file(): + token = Path("/etc/bws-token").read_text().strip() + if not token: + return "" + env = {**os.environ, "BWS_ACCESS_TOKEN": token} + try: + r = subprocess.run( + ["/usr/local/bin/bws", "secret", "get", BWS_SECRET_ID, "--output", "env"], + capture_output=True, text=True, timeout=30, env=env, + ) + if r.returncode != 0: + return "" + # Value is shell-quoted KEY="value" — take first line only. BWS sometimes + # appends "# one or more secrets have been commented-out..." after a + # problematic key rename; only the first line is the real key value. + line = r.stdout.split("\n")[0] + if "=" not in line: + return "" + val = line.split("=", 1)[1].strip() + if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'": + val = val[1:-1] + return val + except Exception: + return "" + + +# ── health check ──────────────────────────────────────────────────────────── +def check_model(model: str, key: str) -> tuple: + """Returns (ok: bool, detail: str). Uses raw HTTP (no litellm dependency). + + NOTE: litellm strips the 'openai/' provider prefix before sending the + request body. 9router resolves bare aliases (e.g. 'claude-opus-4-8') to + its own routing; WITH the prefix it tries the 'openai' provider upstream, + which has no credentials → 404 'No active credentials for provider: openai'. + So we strip the prefix here to mirror exactly what the server sends. + """ + bare = model.split("/", 1)[-1] if "/" in model else model + import urllib.request, urllib.error + body = json.dumps({ + "model": bare, + "messages": [{"role": "user", "content": "Reply with the single word OK"}], + "max_tokens": 10, + }).encode() + req = urllib.request.Request( + f"{ROUTER_BASE}/chat/completions", + data=body, + headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=60) as r: + return r.status == 200, f"HTTP {r.status}" + except urllib.error.HTTPError as e: + err = e.read().decode(errors="replace")[:160].replace("\n", " ") + return False, f"HTTP {e.code}: {err}" + except Exception as e: + return False, f"{type(e).__name__}: {str(e)[:120]}" + + +def main() -> int: + key = get_key() + if not key: + print("⚠️ pr-agent health: cannot fetch router key from BWS (bws unavailable)") + return 1 + + failures = [] + ok_primary, detail = check_model(PRIMARY, key) + if not ok_primary: + failures.append(f"primary {PRIMARY} → {detail}") + for fb in FALLBACKS: + ok, d = check_model(fb, key) + if not ok: + failures.append(f"fallback {fb} → {d}") + + if not failures: + # healthy — clear counter, stay silent + CONSECUTIVE_FAIL_FILE.unlink(missing_ok=True) + return 0 + + # At least one model failed. Count consecutive failures to avoid flapping. + n = 1 + if CONSECUTIVE_FAIL_FILE.exists(): + try: + n = int(CONSECUTIVE_FAIL_FILE.read_text().strip()) + 1 + except ValueError: + n = 1 + CONSECUTIVE_FAIL_FILE.write_text(str(n)) + + if n < 2: + # first failure — could be transient, stay quiet + return 0 + + detail = " | ".join(failures) + key_hash = hashlib.sha256(key.encode()).hexdigest()[:8] + print(f"🚨 pr-agent MODELS FAILING ({n} consecutive checks)\n{detail}\nkey hash {key_hash}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/result b/result index 6df9d27..0a76840 120000 --- a/result +++ b/result @@ -1 +1 @@ -/nix/store/a2a5pi2w5racrrp9f890wgzsc8zb9zgq-pr-agent-server-1.0.0 \ No newline at end of file +/nix/store/s5bji3q16z616smfyvglcnnkkf68dis2-pr-agent-server-1.0.0 \ No newline at end of file diff --git a/run_server.py b/run_server.py index 8301c99..b63295f 100644 --- a/run_server.py +++ b/run_server.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """PR-Agent GitHub App + manifest callback server""" -import os, sys, json +import os, sys, json, time, glob +from pathlib import Path # Configurable paths (systemd Nix deployment keeps secrets outside the store) APP_DIR = os.environ.get("PR_AGENT_APP_DIR", "/var/lib/pr-agent-server") @@ -38,6 +39,15 @@ os.environ["GITHUB_APP__PR_COMMANDS"] = os.environ.get( '["/review --pr_reviewer.require_score_review=true --pr_reviewer.require_security_review=true","/describe","/improve"]', ) +# Analytics folder for PR-Agent structured logs (analytics=True records) +ANALYTICS_DIR = os.environ.get("PR_AGENT_ANALYTICS_DIR", "/var/lib/pr-agent-server/analytics") +os.makedirs(ANALYTICS_DIR, exist_ok=True) +os.environ["CONFIG__ANALYTICS_FOLDER"] = ANALYTICS_DIR + +# Discord webhook for notifications (from BWS secret DISCORD_WEBHOOK_URL) +DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL", "") +DISCORD_ALERT_WEBHOOK_URL = os.environ.get("DISCORD_ALERT_WEBHOOK_URL", "") + sys.path.insert(0, APP_DIR) from pr_agent.servers.github_app import app as pr_agent_app, router as pr_router from fastapi import FastAPI, Request @@ -46,11 +56,164 @@ import httpx from starlette.middleware import Middleware from starlette_context.middleware import RawContextMiddleware +from fastapi.responses import PlainTextResponse, JSONResponse app = FastAPI(middleware=[Middleware(RawContextMiddleware)]) app.include_router(pr_router) +# ── Analytics / Metrics ───────────────────────────────────────────────────── +def _read_analytics_logs(max_files: int = 5) -> list: + """Parse PR-Agent analytics JSON logs (analytics=True records).""" + records = [] + files = sorted(glob.glob(os.path.join(ANALYTICS_DIR, "pr-agent.*.log"))) + for f in files[-max_files:]: + try: + with open(f) as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + rec = json.loads(line) + except json.JSONDecodeError: + continue + # Normalize structure: {message, extra{...}} + extra = rec.get("extra", {}) or {} + if "artifact" in extra and isinstance(extra["artifact"], dict): + extra.update(extra.pop("artifact")) + rec["_extra"] = extra + rec["_file"] = Path(f).name + records.append(rec) + except FileNotFoundError: + continue + return records + + +@app.get("/api/metrics") +async def metrics(): + """Prometheus-style metrics for the PR-Agent server.""" + records = _read_analytics_logs() + total = len(records) + failed = 0 + success = 0 + command_counts = {} + model_failures = {} + for rec in records: + extra = rec.get("_extra", {}) + cmd = extra.get("command", "unknown") + command_counts[cmd] = command_counts.get(cmd, 0) + 1 + msg = rec.get("message", "") + if "Failed to generate" in msg or "error" in msg.lower() and rec.get("level", {}).get("name", "") == "WARNING": + failed += 1 + model = extra.get("model", "unknown") + model_failures[model] = model_failures.get(model, 0) + 1 + else: + success += 1 + lines = [ + "# HELP pr_agent_requests_total Total PR-Agent analytics events", + "# TYPE pr_agent_requests_total counter", + f'pr_agent_requests_total{{status="success"}} {success}', + f'pr_agent_requests_total{{status="failed"}} {failed}', + "# HELP pr_agent_requests_by_command PR-Agent events by command", + "# TYPE pr_agent_requests_by_command counter", + ] + for cmd, cnt in sorted(command_counts.items()): + lines.append(f'pr_agent_requests_by_command{{command="{cmd}"}} {cnt}') + lines.append("# HELP pr_agent_model_failures PR-Agent model failures by model") + lines.append("# TYPE pr_agent_model_failures counter") + for model, cnt in sorted(model_failures.items()): + lines.append(f'pr_agent_model_failures{{model="{model}"}} {cnt}') + return PlainTextResponse( + "\n".join(lines) + "\n", + media_type="text/plain; version=0.0.4; charset=utf-8", + ) + + +@app.get("/api/analytics") +async def analytics(): + """JSON analytics summary — recent events + failure breakdown.""" + records = _read_analytics_logs() + recent = [] + for rec in records[-30:]: + extra = rec.get("_extra", {}) + recent.append( + { + "time": rec.get("time", {}).get("repr", ""), + "command": extra.get("command", ""), + "message": rec.get("message", ""), + "pr_url": extra.get("pr_url", ""), + "model": extra.get("model", ""), + "level": rec.get("level", {}).get("name", ""), + } + ) + failures = [r for r in records if "Failed to generate" in r.get("message", "")] + return { + "total_events": len(records), + "failure_count": len(failures), + "recent": recent, + "failures": [ + { + "time": r.get("time", {}).get("repr", ""), + "command": r.get("_extra", {}).get("command", ""), + "model": r.get("_extra", {}).get("model", ""), + "message": r.get("message", "")[:200], + } + for r in failures[-20:] + ], + } + + +# ── Discord notifications ─────────────────────────────────────────────────── +async def _send_discord(webhook: str, content: str, title: str = "", color: int = 0x5865F2): + """Fire-and-forget Discord webhook message. Never raises.""" + if not webhook: + return False + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + webhook, + json={ + "username": "PR-Agent Ops", + "embeds": [{"title": title, "description": content[:4000], "color": color}], + }, + ) + return resp.status_code in (200, 204) + except Exception: + return False + + +@app.post("/api/v1/notify_review") +async def notify_review(request: Request): + """Internal endpoint: pr-agent/queue worker posts here after a review completes.""" + try: + body = await request.json() + except Exception: + body = {} + repo = body.get("repo", "") + pr_num = body.get("pr", "") + status = body.get("status", "done") # done | failed + summary = body.get("summary", "") + score = body.get("score", "") + url = body.get("url", "") + + if status == "failed": + await _send_discord( + DISCORD_ALERT_WEBHOOK_URL or DISCORD_WEBHOOK_URL, + f"**{repo}** PR #{pr_num} review FAILED\n```{summary}```\n{url}", + title="🚨 PR-Agent Review Failed", + color=0xED4245, + ) + else: + await _send_discord( + DISCORD_WEBHOOK_URL, + f"**{repo}** PR #{pr_num} reviewed" + (f" — score {score}/10" if score else "") + f"\n{summary}\n{url}", + title="✅ PR-Agent Review Complete", + color=0x57F287, + ) + return {"ok": True} + + @app.get("/setup/callback") @app.post("/setup/callback") async def callback(request: Request): @@ -82,7 +245,7 @@ async def callback(request: Request): @app.get("/health") async def health(): - return {"status": "ok"} + return {"status": "ok", "model": os.environ.get("PR_AGENT_MODEL", "")} if __name__ == "__main__": @@ -91,5 +254,6 @@ if __name__ == "__main__": print(f" App ID: {os.environ.get('GITHUB_APP_ID', '')}") print(f" Model: {os.environ.get('PR_AGENT_MODEL', 'openai/claude-opus-4-8')} via omniroute") print(f" Endpoint: /api/v1/github_webhooks") + print(f" Analytics: {ANALYTICS_DIR}") print(f" Port: {port}") uvicorn.run(app, host="0.0.0.0", port=port, log_level="info") diff --git a/sync-key.py b/sync-key.py new file mode 100644 index 0000000..12159f8 --- /dev/null +++ b/sync-key.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +PR-Agent key auto-sync — fetch the working 9router key from Bitwarden Secrets +Manager (BWS) and write it to the on-disk omniroute_key file IF it differs. + +Why: the on-disk key file is the single source of truth for the running +server (run_server.py reads it at startup). If BWS gets updated (key +rotation) and the file isn't refreshed, the server silently starts failing +with 401s — exactly what happened 2026-08-04 (stale 35-char key for 14h). + +This script is invoked by systemd ExecStartPre= so every service start / +restart re-syncs the key before uvicorn boots. It is idempotent and +fail-open (on any BWS error it leaves the existing file untouched so the +service can still start). +""" +import os, sys, subprocess, hashlib +from pathlib import Path + +APP_DIR = Path(os.environ.get("PR_AGENT_APP_DIR", "/var/lib/pr-agent-server")) +KEYFILE = APP_DIR / "omniroute_key" +# BWS secret that holds the working router key +BWS_SECRET_ID = os.environ.get("BWS_ROUTER_KEY_SECRET_ID", "2aef2194-971d-4dae-99dd-b49a0041f97c") +PROJECT_ID = "27210268-6134-47b3-9a68-b4980079d1ec" + + +def sha(s: str) -> str: + return hashlib.sha256(s.encode()).hexdigest() + + +def bws_get_secret_value(secret_id: str) -> str: + """Fetch a BWS secret value. Returns '' on any failure (fail-open).""" + token = os.environ.get("BWS_ACCESS_TOKEN", "") + if not token and Path("/etc/bws-token").is_file(): + token = Path("/etc/bws-token").read_text().strip() + if not token: + print("sync-key: no BWS_ACCESS_TOKEN", file=sys.stderr) + return "" + env = {**os.environ, "BWS_ACCESS_TOKEN": token} + try: + r = subprocess.run( + ["/usr/local/bin/bws", "secret", "get", secret_id, "--output", "env"], + capture_output=True, text=True, timeout=30, env=env, + ) + if r.returncode != 0: + print(f"sync-key: bws get failed rc={r.returncode}: {r.stderr[:200]}", file=sys.stderr) + return "" + # Value is shell-quoted KEY="value" — take first line, strip quotes + line = r.stdout.split("\n")[0] + if "=" not in line: + return "" + val = line.split("=", 1)[1].strip() + # Remove wrapping quotes (shlex could be used; simple strip is fine for keys) + if val.startswith('"') and val.endswith('"'): + val = val[1:-1] + elif val.startswith("'") and val.endswith("'"): + val = val[1:-1] + return val + except Exception as e: + print(f"sync-key: bws error {type(e).__name__}: {str(e)[:200]}", file=sys.stderr) + return "" + + +def main() -> int: + new_key = bws_get_secret_value(BWS_SECRET_ID).strip() + if not new_key: + print("sync-key: no key from BWS, leaving existing file", file=sys.stderr) + return 0 # fail-open + + if KEYFILE.exists(): + old = KEYFILE.read_text().strip() + if old == new_key: + print("sync-key: key already up to date (no change)") + return 0 + + # Write key with correct owner/perms (pr-agent user) + try: + import pwd + pw = pwd.getpwnam("pr-agent") + KEYFILE.write_text(new_key + "\n") + os.chmod(KEYFILE, 0o600) + os.chown(KEYFILE, pw.pw_uid, pw.pw_gid) + print(f"sync-key: updated {KEYFILE} ({sha(new_key)[:12]}...)") + return 0 + except Exception as e: + print(f"sync-key: write failed {type(e).__name__}: {str(e)[:200]}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/trivial_merge.py b/trivial_merge.py new file mode 100644 index 0000000..a44c898 --- /dev/null +++ b/trivial_merge.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""PR-Agent Trivial PR Auto-Merge — enhances auto_merge_bot.py with trivial-PR fast-path. + +Logic: + - PR with bot review comment ("PR Reviewer Guide") + score >= 5 + - AND is_trivial (docs-only, version bump, dependency bump, < small diff) + → skip AI fix, approve + merge directly if CI is green. + +This file is imported/executed by auto_merge_bot.py; keep it standalone and +dependency-light (httpx, pyjwt). +""" +import os, re, time, json +from pathlib import Path + +# ── Config ── +APP_ID = os.environ.get("GITHUB_APP_ID", "4319749") +PRIVATE_KEY_PATH = os.environ.get("PRIVATE_KEY_PATH", "/var/lib/pr-agent-server/private-key.pem") +BASE_URL = os.environ.get("GITHUB_API_BASE", "https://api.github.com") +BOT_LOGIN = os.environ.get("PR_AGENT_BOT_LOGIN", "mytheclipsebotreview") +TRIVIAL_MAX_DIFF_LINES = int(os.environ.get("TRIVIAL_MAX_DIFF_LINES", "100")) +TRIVIAL_MAX_FILES = int(os.environ.get("TRIVIAL_MAX_FILES", "5")) + +TRIVIAL_TITLE_RE = re.compile( + r"(dependabot|update|upgrade|bump|chore\(deps\)|pin dependencies|" + r"docs?[:\(]|version|release|backport|typo|fix typo|minor|patch)", + re.IGNORECASE, +) +TRIVIAL_FILE_RE = re.compile( + r"(\.md$|\.txt$|\.lock$|\.gitignore$|\.dockerignore$|README|LICENSE|" + r"CHANGELOG|package\.json$|pyproject\.toml$|Cargo\.toml$|go\.mod$|Gemfile\.lock$|" + r"requirements.*\.txt$|\.github/workflows/|\.env\.example$)", + re.IGNORECASE, +) + + +def is_trivial_pr(title: str, author: str, changed_files: list, total_lines: int) -> bool: + """Determine if a PR is 'trivial' — safe to auto-merge without AI fix.""" + if author == BOT_LOGIN or "[bot]" in author: + return True + if total_lines > TRIVIAL_MAX_DIFF_LINES: + return False + if len(changed_files) > TRIVIAL_MAX_FILES: + return False + if TRIVIAL_TITLE_RE.search(title): + return True + # all changed files trivial? + if changed_files and all(TRIVIAL_FILE_RE.search(f) for f in changed_files): + return True + return False + + +def get_pr_changed_files(token: str, repo_full: str, pr_number: int) -> tuple: + """Return (changed_files: list, total_added+deleted: int) via GitHub API.""" + import httpx + files = [] + total = 0 + page = 1 + with httpx.Client(timeout=30) as client: + while True: + r = client.get( + f"{BASE_URL}/repos/{repo_full}/pulls/{pr_number}/files", + params={"per_page": 100, "page": page}, + headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}, + ) + if r.status_code != 200: + break + batch = r.json() + if not batch: + break + for f in batch: + files.append(f.get("filename", "")) + total += f.get("additions", 0) + f.get("deletions", 0) + if len(batch) < 100: + break + page += 1 + return files, total + + +def check_ci_passed(token: str, repo_full: str, sha: str) -> tuple: + """Check GitHub check-runs/status for a SHA. Returns (ok, msg).""" + import httpx + with httpx.Client(timeout=30) as client: + r = client.get( + f"{BASE_URL}/repos/{repo_full}/commits/{sha}/check-runs", + headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}, + ) + if r.status_code == 200: + data = r.json() + runs = data.get("check_runs", []) + if not runs: + return True, "No CI configured" + for run in runs: + status = run.get("status", "") + conclusion = run.get("conclusion") + if status != "completed": + return False, f"Check pending: {run.get('name','?')}" + if conclusion not in ("success", "neutral", "skipped"): + return False, f"Check failed: {run.get('name','?')} → {conclusion}" + return True, f"CI green ({len(runs)} checks)" + # fallback to statuses + r2 = client.get( + f"{BASE_URL}/repos/{repo_full}/commits/{sha}/status", + headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}, + ) + if r2.status_code == 200: + st = r2.json().get("state", "") + if st == "success": + return True, "Status success" + if st == "pending": + return False, "Status pending" + return False, f"Status {st}" + return True, "No CI configured" + + +def approve_pr(token: str, repo_full: str, pr_number: int) -> int: + import httpx + with httpx.Client(timeout=30) as client: + r = client.post( + f"{BASE_URL}/repos/{repo_full}/pulls/{pr_number}/reviews", + headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}, + json={"event": "APPROVE", "body": "✅ Auto-approved (trivial PR)."}, + ) + return r.status_code + + +def merge_pr(token: str, repo_full: str, pr_number: int, sha: str) -> tuple: + import httpx + with httpx.Client(timeout=30) as client: + r = client.put( + f"{BASE_URL}/repos/{repo_full}/pulls/{pr_number}/merge", + headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}, + json={"commit_title": f"Auto-merge trivial PR #{pr_number}", "merge_method": "squash", "sha": sha}, + ) + if r.status_code == 200: + return True, f"Merged: {r.json().get('sha','?')}" + return False, f"Merge failed: {r.status_code} - {r.json().get('message','')}"