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:
@@ -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','')}"
|
||||
Reference in New Issue
Block a user