245 lines
9.2 KiB
Python
245 lines
9.2 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""
|
||
|
|
PR-Agent Auto-Approve + Auto-Merge Bot
|
||
|
|
Runs periodically (cron), finds open PRs that have been reviewed by PR-Agent,
|
||
|
|
approves them and enables auto-merge.
|
||
|
|
"""
|
||
|
|
import os, sys, json, time, hmac, hashlib, asyncio
|
||
|
|
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")
|
||
|
|
WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET", "")
|
||
|
|
BASE_URL = os.environ.get("GITHUB_API_BASE", "https://api.github.com")
|
||
|
|
|
||
|
|
def get_jwt():
|
||
|
|
import jwt as pyjwt
|
||
|
|
with open(PRIVATE_KEY_PATH) as f:
|
||
|
|
key = f.read()
|
||
|
|
now = int(time.time())
|
||
|
|
payload = {"iat": now - 60, "exp": now + 600, "iss": APP_ID}
|
||
|
|
return pyjwt.encode(payload, key, algorithm="RS256")
|
||
|
|
|
||
|
|
def get_installation_token(installation_id: int) -> str:
|
||
|
|
"""Get installation access token"""
|
||
|
|
import httpx
|
||
|
|
jwt_token = get_jwt()
|
||
|
|
with httpx.Client() as client:
|
||
|
|
r = client.post(
|
||
|
|
f"{BASE_URL}/app/installations/{installation_id}/access_tokens",
|
||
|
|
headers={"Authorization": f"Bearer {jwt_token}", "Accept": "application/vnd.github.v3+json"}
|
||
|
|
)
|
||
|
|
return r.json().get("token", "")
|
||
|
|
|
||
|
|
def get_all_installations() -> list:
|
||
|
|
"""Get all app installations"""
|
||
|
|
jwt_token = get_jwt()
|
||
|
|
import httpx
|
||
|
|
with httpx.Client() as client:
|
||
|
|
r = client.get(
|
||
|
|
f"{BASE_URL}/app/installations",
|
||
|
|
headers={"Authorization": f"Bearer {jwt_token}", "Accept": "application/vnd.github.v3+json"}
|
||
|
|
)
|
||
|
|
return r.json()
|
||
|
|
|
||
|
|
def get_installation_repos(installation_id: int, token: str) -> list:
|
||
|
|
"""Get repos for an installation"""
|
||
|
|
import httpx
|
||
|
|
with httpx.Client() as client:
|
||
|
|
r = client.get(
|
||
|
|
f"{BASE_URL}/installation/repositories",
|
||
|
|
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
|
||
|
|
)
|
||
|
|
return r.json().get("repositories", [])
|
||
|
|
|
||
|
|
def get_open_prs(token: str, repo_full: str) -> list:
|
||
|
|
"""Get open PRs in a repo"""
|
||
|
|
import httpx
|
||
|
|
with httpx.Client() as client:
|
||
|
|
r = client.get(
|
||
|
|
f"{BASE_URL}/repos/{repo_full}/pulls?state=open&sort=updated&direction=desc",
|
||
|
|
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
|
||
|
|
)
|
||
|
|
return r.json()
|
||
|
|
|
||
|
|
def get_pr_reviews(token: str, repo_full: str, pr_number: int) -> list:
|
||
|
|
"""Get reviews for a PR"""
|
||
|
|
import httpx
|
||
|
|
with httpx.Client() as client:
|
||
|
|
r = client.get(
|
||
|
|
f"{BASE_URL}/repos/{repo_full}/pulls/{pr_number}/reviews",
|
||
|
|
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
|
||
|
|
)
|
||
|
|
return r.json()
|
||
|
|
|
||
|
|
def get_pr_comments(token: str, repo_full: str, pr_number: int) -> list:
|
||
|
|
"""Get issue comments for a PR"""
|
||
|
|
import httpx
|
||
|
|
with httpx.Client() as client:
|
||
|
|
r = client.get(
|
||
|
|
f"{BASE_URL}/repos/{repo_full}/issues/{pr_number}/comments",
|
||
|
|
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
|
||
|
|
)
|
||
|
|
return r.json()
|
||
|
|
|
||
|
|
def approve_pr(token: str, repo_full: str, pr_number: int) -> bool:
|
||
|
|
"""Submit APPROVE review"""
|
||
|
|
import httpx
|
||
|
|
with httpx.Client() 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 by PR-Agent bot."}
|
||
|
|
)
|
||
|
|
return r.status_code == 200
|
||
|
|
|
||
|
|
def merge_pr(token: str, repo_full: str, pr_number: int) -> tuple:
|
||
|
|
"""Attempt to merge the PR"""
|
||
|
|
import httpx
|
||
|
|
with httpx.Client() as client:
|
||
|
|
# Get PR info for SHA
|
||
|
|
pr_r = client.get(
|
||
|
|
f"{BASE_URL}/repos/{repo_full}/pulls/{pr_number}",
|
||
|
|
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
|
||
|
|
)
|
||
|
|
if pr_r.status_code != 200:
|
||
|
|
return False, f"Can't get PR: {pr_r.status_code}"
|
||
|
|
|
||
|
|
pr_data = pr_r.json()
|
||
|
|
sha = pr_data.get("head", {}).get("sha", "")
|
||
|
|
mergeable = pr_data.get("mergeable", False)
|
||
|
|
|
||
|
|
if mergeable is False:
|
||
|
|
return False, "PR not mergeable (conflicts or checks pending)"
|
||
|
|
|
||
|
|
# Try merge
|
||
|
|
merge_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 PR #{pr_number}",
|
||
|
|
"merge_method": "merge",
|
||
|
|
"sha": sha
|
||
|
|
}
|
||
|
|
)
|
||
|
|
if merge_r.status_code == 200:
|
||
|
|
return True, f"Merged: {merge_r.json().get('sha', '')}"
|
||
|
|
else:
|
||
|
|
return False, f"Merge failed: {merge_r.status_code} - {merge_r.json().get('message', '')}"
|
||
|
|
|
||
|
|
def has_bot_comment_with_review(comments: list) -> tuple:
|
||
|
|
"""Check if PR-Agent has posted a review comment and extract quality"""
|
||
|
|
bot_name = "mytheclipsebotreview"
|
||
|
|
for c in comments:
|
||
|
|
if c.get("user", {}).get("login", "").startswith(bot_name):
|
||
|
|
body = c.get("body", "")
|
||
|
|
# Check for PR Reviewer Guide (successful review)
|
||
|
|
if "PR Reviewer Guide" in body:
|
||
|
|
# Extract score if available
|
||
|
|
score = extract_score(body)
|
||
|
|
return True, score
|
||
|
|
return False, 0
|
||
|
|
|
||
|
|
def extract_score(body: str) -> int:
|
||
|
|
"""Extract review score from bot comment"""
|
||
|
|
import re
|
||
|
|
# Look for score patterns like "Score: 8" or "⏱️ Estimated effort"
|
||
|
|
# For now, assume passing if we got a review without errors
|
||
|
|
return 8
|
||
|
|
|
||
|
|
def main():
|
||
|
|
print("=" * 60)
|
||
|
|
print(f"PR-Agent Auto-Approve/Merge Bot - {time.ctime()}")
|
||
|
|
print("=" * 60)
|
||
|
|
|
||
|
|
# Get installations
|
||
|
|
installations = get_all_installations()
|
||
|
|
print(f"Found {len(installations)} installation(s)")
|
||
|
|
|
||
|
|
for inst in installations:
|
||
|
|
inst_id = inst["id"]
|
||
|
|
account = inst["account"]["login"]
|
||
|
|
print(f"\n📦 Installation {inst_id} - @{account}")
|
||
|
|
|
||
|
|
# Get token
|
||
|
|
token = get_installation_token(inst_id)
|
||
|
|
if not token:
|
||
|
|
print(f" ❌ Failed to get token")
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Get repos
|
||
|
|
repos = get_installation_repos(inst_id, token)
|
||
|
|
print(f" Repos: {len(repos)}")
|
||
|
|
|
||
|
|
for repo in repos:
|
||
|
|
repo_full = repo["full_name"]
|
||
|
|
print(f"\n 📁 {repo_full}")
|
||
|
|
|
||
|
|
# Get open PRs
|
||
|
|
prs = get_open_prs(token, repo_full)
|
||
|
|
print(f" Open PRs: {len(prs)}")
|
||
|
|
|
||
|
|
for pr in prs[:5]: # Max 5 per repo
|
||
|
|
pr_num = pr["number"]
|
||
|
|
pr_title = pr["title"]
|
||
|
|
pr_user = pr["user"]["login"]
|
||
|
|
pr_author = pr_user
|
||
|
|
|
||
|
|
print(f" 🔀 PR #{pr_num}: {pr_title[:50]}...")
|
||
|
|
|
||
|
|
# Skip bot PRs
|
||
|
|
if "[bot]" in pr_author or pr_author == "mytheclipsebotreview":
|
||
|
|
print(f" ⏭️ Bot PR, skipping")
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Check if already approved/merged
|
||
|
|
if pr.get("merged", False):
|
||
|
|
print(f" ✅ Already merged")
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Check reviews
|
||
|
|
reviews = get_pr_reviews(token, repo_full, pr_num)
|
||
|
|
bot_approved = any(
|
||
|
|
r.get("user", {}).get("login", "").startswith("mytheclipsebotreview")
|
||
|
|
and r.get("state") == "APPROVED"
|
||
|
|
for r in reviews
|
||
|
|
)
|
||
|
|
|
||
|
|
if bot_approved:
|
||
|
|
print(f" ✅ Already approved. Trying merge...")
|
||
|
|
success, msg = merge_pr(token, repo_full, pr_num)
|
||
|
|
print(f" {'✅' if success else '❌'} Merge: {msg}")
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Check bot comments for review
|
||
|
|
comments = get_pr_comments(token, repo_full, pr_num)
|
||
|
|
has_review, score = has_bot_comment_with_review(comments)
|
||
|
|
|
||
|
|
if has_review and score >= 5:
|
||
|
|
print(f" 📝 Review found (score: {score}). Approving + merging...")
|
||
|
|
|
||
|
|
# Approve
|
||
|
|
if approve_pr(token, repo_full, pr_num):
|
||
|
|
print(f" ✅ Approved!")
|
||
|
|
else:
|
||
|
|
print(f" ❌ Approve failed")
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Small delay
|
||
|
|
time.sleep(2)
|
||
|
|
|
||
|
|
# Merge
|
||
|
|
success, msg = merge_pr(token, repo_full, pr_num)
|
||
|
|
print(f" {'✅ Merged!' if success else '❌ ' + msg}")
|
||
|
|
elif has_review and score < 5:
|
||
|
|
print(f" ⏭️ Review score too low ({score})")
|
||
|
|
else:
|
||
|
|
print(f" ⏳ No bot review yet")
|
||
|
|
|
||
|
|
print("\n" + "=" * 60)
|
||
|
|
print("Done!")
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|