Compare commits

...
9 Commits
Author SHA1 Message Date
asepharyana 2293428421 fix: health watchdog false 504s — Caddy header timeout + per-model logic
Root cause: 9router combo models (deepseek-v4-flash-free on fallback) have
TTFT up to 30-40s. Caddy 9router route inherited the default
response_header_timeout 30s / read_timeout 60s → 504 'timeout awaiting
response headers' even though 9router was still processing. Cloudflare/log
showed repeated 504s; health watchdog (correctly) flagged the outage.

Fixes:
1. Caddy: dedicated 9router route with response_header_timeout 120s +
   read/write 300s (was default 30/60). Removed invalid top-level
   flush_interval on upload block that broke caddy reload (2.11 rejects it as
   transport subdirective).
2. health-check: HTTP timeout 60→150s (mirror Caddy), and alert ONLY when
   EVERY model fails — any working model means the server's fallback chain
   succeeds. Early-exit on first success to bound runtime (~3s healthy).
Verified: 3 runs green, ~3.6s each, silent exit 0.
2026-08-04 21:06:54 +07:00
asepharyana bd3d739250 ci: add Nix GC cleanup job on VPS after deploy 2026-08-04 13:57:47 +07:00
asepharyana 875ddfcca9 fix: health-check BWS token read as non-root cron user
Cron runs as user code (not root). /etc/bws-token is root:bws 640, so direct
read fails with Permission denied → watchdog exited 1 every run. Fix:
- wrapper uses sudo -n cat (code is in sudo group, NOPASSWD)
- health-check.py get_key() falls back to sudo -n cat too
Verified as code user: silent exit 0 when healthy.
2026-08-04 12:12:42 +07:00
asepharyana 407819b647 fix: parse PR-Agent analytics record-wrapped JSON format
Real PR-Agent analytics logs wrap fields under 'record': {...}. The parser
now unwraps that before extracting command/pr_url/message/level, so
/api/analytics and /api/metrics show real data (verified with actual format
from production logs).
2026-08-04 11:43:13 +07:00
asepharyana efb99b73b7 chore: gitignore nix result symlink 2026-08-04 11:39:34 +07:00
asepharyana 690f96aacb 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
2026-08-04 11:39:29 +07:00
asepharyana 06f3314819 fix: replace broken openai/auto fallback models with working 9router aliases
openai/auto/best-coding and openai/auto/claude-sonnet return
'No active credentials for provider: auto' on 9router (broken upstream
key). Primary openai/claude-opus-4-8 + fallbacks now all verified
working via litellm against 9router.asepharyana.my.id.
2026-08-04 10:17:29 +07:00
aseph ca67f0328b ci: use free GHA Nix cache (disable FlakeHub cache, not subscribed) 2026-08-03 16:44:18 +07:00
asepharyana fa0e031ba9 ci: enable FlakeHub Cache (id-token: write + use-flakehub) 2026-08-03 16:22:48 +07:00
9 changed files with 650 additions and 4 deletions
+22
View File
@@ -11,6 +11,7 @@ concurrency:
permissions:
contents: read
id-token: write
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
@@ -47,6 +48,8 @@ jobs:
- name: Cache Nix
uses: DeterminateSystems/magic-nix-cache-action@v14
with:
use-flakehub: false
- name: Build pr-agent-server
id: build
@@ -79,3 +82,22 @@ jobs:
echo "=== Restarting service ==="
ssh "$VPS_USER@$VPS_HOST" "sudo systemctl daemon-reload && sudo systemctl restart pr-agent-server && sleep 3 && sudo systemctl is-active pr-agent-server"
echo "✅ pr-agent-server deployed"
cleanup:
# Bersihkan sampah Nix di VPS SETELAH deploy: hapus generasi profile lama
# + nix store gc. Profil yang sedang dipakai tidak disentuh.
needs: build-and-deploy
if: always()
runs-on: ubuntu-latest
steps:
- name: Nix GC on VPS
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
ssh "$VPS_USER@$VPS_HOST" "sudo /usr/local/bin/nix-gc-vps.sh" || echo "⚠️ Nix GC gagal (non-fatal)"
+1
View File
@@ -9,3 +9,4 @@ whsec*.txt
# Python
__pycache__/
*.pyc
result
+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
'';
};
});
+157
View File
@@ -0,0 +1,157 @@
#!/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 raw HTTP.
Output contract (no_agent cron):
- OK → empty stdout (silent, $0 idle)
- FAIL → one-line alert + detail (delivered to Discord/home channel)
Design: alert only when EVERY configured model fails (primary AND all
fallbacks). If any model works, the server's own fallback chain will succeed,
so the system is healthy even if the primary is down/slow. This prevents
false alerts from a single slow/failed model.
"""
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"]
# Caddy 9router route is now response_header_timeout 120s / read 300s.
# LLM combo TTFT often 30-40s+. Give the check room to complete.
HTTP_TIMEOUT = 150
CONSECUTIVE_FAIL_FILE = Path("/tmp/pr-agent-health-fail-count")
# ── key from BWS ────────────────────────────────────────────────────────────
def _read_token() -> str:
"""Read BWS token. Direct read fails for non-root (root:bws 640), so fall
back to `sudo -n cat` (cron user `code` is in sudo group, NOPASSWD)."""
for path in (Path("/etc/bws-token"),):
try:
if path.is_file():
return path.read_text().strip()
except PermissionError:
pass
try:
r = subprocess.run(["sudo", "-n", "cat", "/etc/bws-token"],
capture_output=True, text=True, timeout=10)
if r.returncode == 0:
return r.stdout.strip()
except Exception:
pass
return ""
def get_key() -> str:
token = os.environ.get("BWS_ACCESS_TOKEN", "")
if not token:
token = _read_token()
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..."; 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().strip('"')
if len(val) < 10:
return ""
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=HTTP_TIMEOUT) 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
results = {}
ok_somewhere = False
results[PRIMARY] = check_model(PRIMARY, key)
ok_somewhere = ok_somewhere or results[PRIMARY][0]
if not ok_somewhere:
for fb in FALLBACKS:
results[fb] = check_model(fb, key)
if results[fb][0]:
ok_somewhere = True
break # bound runtime; one working model is enough
else:
# ensure every fallback appears in results for the report
for fb in FALLBACKS:
results.setdefault(fb, (False, "not tested (prior model failed)"))
else:
for fb in FALLBACKS:
results.setdefault(fb, (True, "not checked (primary ok)"))
# Any model working = server's fallback chain will succeed = healthy.
if ok_somewhere:
CONSECUTIVE_FAIL_FILE.unlink(missing_ok=True)
return 0
# Every model failed. Count consecutive to avoid flapping on 1-off glitch.
failures = [f"{m}{d}" for m, (ok, d) in results.items() if not ok]
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:
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
View File
@@ -1 +0,0 @@
/nix/store/a2a5pi2w5racrrp9f890wgzsc8zb9zgq-pr-agent-server-1.0.0
+176 -3
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")
@@ -28,7 +29,7 @@ os.environ["OPENAI__KEY"] = omni_key
os.environ["CONFIG__MODEL"] = os.environ.get("PR_AGENT_MODEL", "openai/claude-opus-4-8")
os.environ["CONFIG__FALLBACK_MODELS"] = os.environ.get(
"PR_AGENT_FALLBACK_MODELS",
'["openai/auto/best-coding","openai/auto/claude-sonnet"]',
'["openai/ATLAS","openai/gemini","openai/text","openai/deepseek-v4-flash-free"]',
)
os.environ["CONFIG__CUSTOM_MODEL_MAX_TOKENS"] = os.environ.get(
"PR_AGENT_MAX_TOKENS", "128000"
@@ -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,173 @@ 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).
Real log lines look like:
{"text": "...", "record": {"elapsed": {...}, "extra": {"command": "...", "pr_url": "..."},
"file": {...}, "function": "...", "level": {"name": "INFO", ...},
"message": "...", "module": "...", "process": {...}, "thread": {...},
"time": {"repr": "2026-08-04 ...", "timestamp": ...}}}
"""
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
# PR-Agent wraps under "record": {...}
if "record" in rec and isinstance(rec["record"], dict):
rec = rec["record"]
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 +254,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 +263,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','')}"