Files
pr-agent-server/trivial_merge.py
T

137 lines
5.5 KiB
Python
Raw Normal View History

#!/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','')}"