feat: add health watchdog, key auto-sync, analytics, Discord notifications, trivial-PR merge

- health-check.py: model health watchdog (silent when healthy, alert on 2+
  consecutive failures) — catches stale-key/model-breakage like 2026-08-04
- sync-key.py: systemd ExecStartPre syncs 9router key from BWS to disk,
  prevents silent 401s after key rotation
- run_server.py: CONFIG__ANALYTICS_FOLDER + /api/metrics (Prometheus) +
  /api/analytics (JSON) + /api/v1/notify_review (Discord webhook)
- trivial_merge.py: trivial-PR fast-path (docs/deps/tiny diffs) approve+merge
- auto_merge_bot.py: Discord notifications on merge, trivial integration
- flake.nix: ship pr-agent-sync-key + pr-agent-health-check binaries
This commit is contained in:
asepharyana
2026-08-04 11:39:29 +07:00
parent 06f3314819
commit 690f96aacb
7 changed files with 583 additions and 3 deletions
+50
View File
@@ -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...")
+18
View File
@@ -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
'';
};
});
+122
View File
@@ -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())
+1 -1
View File
@@ -1 +1 @@
/nix/store/a2a5pi2w5racrrp9f890wgzsc8zb9zgq-pr-agent-server-1.0.0
/nix/store/s5bji3q16z616smfyvglcnnkkf68dis2-pr-agent-server-1.0.0
+166 -2
View File
@@ -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")
+90
View File
@@ -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())
+136
View File
@@ -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','')}"