commit 163a1ef7abc11d88f9bd3c5e927c045ec295d6da Author: MythEclipse Date: Fri Jul 31 09:35:03 2026 +0700 feat: PR-Agent GitHub App server — sanitized source + Nix flake + CI deploy diff --git a/.gitea/workflows/nix-deploy.yml b/.gitea/workflows/nix-deploy.yml new file mode 100644 index 0000000..9931a2b --- /dev/null +++ b/.gitea/workflows/nix-deploy.yml @@ -0,0 +1,65 @@ +name: Build & Deploy (Nix) + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + run: | + git clone https://git.imrnes.team/MythEclipse/pr-agent-server.git . + git checkout ${{ github.sha }} + + - name: Build & Deploy + env: + VPS_HOST: ${{ secrets.VPS_HOST }} + VPS_USER: ${{ secrets.VPS_USER }} + VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }} + run: | + set -eu + + # --- Install Nix & Build --- + curl -fsSL https://install.determinate.systems/nix \ + | sh -s -- install linux --no-confirm --init none 2>&1 + + mkdir -p /etc/nix + echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf + + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + + echo "=== Building: pr-agent-server ===" + nix build .#default --impure --option sandbox false 2>&1 + + STORE_PATH=$(readlink result) + echo "=== Store path: $STORE_PATH" + + # --- Deploy --- + NIX_BIN="/nix/var/nix/profiles/default/bin" + PROFILE="/nix/var/nix/profiles/pr-agent-server" + + key_file=$(mktemp /tmp/deploy-key.XXXXXX) + printf '%s\n' "$VPS_SSH_KEY" > "$key_file" + chmod 600 "$key_file" + sed -i 's/\r$//' "$key_file" # strip DOS line endings if any + + export NIX_SSHOPTS="-i $key_file -o StrictHostKeyChecking=no" + nix copy --to "ssh://${VPS_USER}@${VPS_HOST}" "$STORE_PATH" 2>&1 + + ssh -i "$key_file" -o StrictHostKeyChecking=no \ + "${VPS_USER}@${VPS_HOST}" " + export PATH=\$PATH:$NIX_BIN + if [ -d $PROFILE ] && [ ! -L $PROFILE ]; then + rm -rf $PROFILE + fi + nix-env --profile $PROFILE --set $STORE_PATH + systemctl daemon-reload + systemctl restart pr-agent-server + sleep 3 + systemctl status pr-agent-server --no-pager 2>&1 | head -12 + " 2>&1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2aced93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Secrets — never commit +*.pem +*credentials*.json +webhook_secret.txt +whsec*.txt +.secrets.toml +.env + +# Python +__pycache__/ +*.pyc diff --git a/auto_merge_bot.py b/auto_merge_bot.py new file mode 100644 index 0000000..f6edcf8 --- /dev/null +++ b/auto_merge_bot.py @@ -0,0 +1,244 @@ +#!/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() diff --git a/callback_server.py b/callback_server.py new file mode 100644 index 0000000..2bd2487 --- /dev/null +++ b/callback_server.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Quick callback server to receive GitHub App credentials after manifest creation""" +import json, os, sys +sys.path.insert(0, os.path.expanduser("~/hermes-agent/.venv/lib/python3.12/site-packages")) + +from fastapi import FastAPI, Request +import uvicorn + +app = FastAPI() + +@app.get("/setup/callback") +@app.post("/setup/callback") +async def callback(request: Request): + params = dict(request.query_params) + print(f"[CALLBACK] Received params: {json.dumps(params, indent=2)}") + + # If we got a code, exchange it for credentials + if "code" in params: + import httpx + code = params["code"] + print(f"[CALLBACK] Exchanging code: {code[:20]}...") + + async with httpx.AsyncClient() as client: + resp = await client.post( + f"https://api.github.com/app-manifests/{code}/conversions", + headers={"Accept": "application/vnd.github.v3+json"} + ) + if resp.status_code == 201: + data = resp.json() + # Save credentials + creds = { + "app_id": data.get("id"), + "app_slug": data.get("slug"), + "pem": data.get("pem"), + "webhook_secret": data.get("webhook_secret"), + "client_id": data.get("client_id"), + "client_secret": data.get("client_secret") + } + with open("/opt/pr-agent-server/app_credentials.json", "w") as f: + json.dump(creds, f, indent=2) + print(f"[CALLBACK] App created! ID: {creds['app_id']}, Slug: {creds['app_slug']}") + return {"status": "success", "app_id": creds["app_id"], "app_slug": creds["app_slug"]} + else: + print(f"[CALLBACK] Exchange failed: {resp.status_code} - {resp.text}") + return {"status": "error", "detail": resp.text} + + return {"status": "waiting", "params": params} + +@app.get("/health") +async def health(): + return {"status": "ok"} + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=3000, log_level="info") diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..9a97210 --- /dev/null +++ b/flake.nix @@ -0,0 +1,52 @@ +{ + description = "PR-Agent Server — GitHub App webhook server (Nix build)"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + python = pkgs.python312; + in { + packages.default = pkgs.stdenv.mkDerivation { + pname = "pr-agent-server"; + version = "1.0.0"; + src = ./.; + + nativeBuildInputs = [ python pkgs.git pkgs.cacert ]; + + buildPhase = '' + export HOME=$TMPDIR/home + mkdir -p "$HOME" + export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt + export NODE_EXTRA_CA_CERTS=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt + + echo "=== Creating venv ===" + python -m venv $out/venv + $out/venv/bin/pip install --no-cache-dir --upgrade pip 2>&1 + + echo "=== pip install deps ===" + $out/venv/bin/pip install --no-cache-dir \ + pr-agent fastapi uvicorn httpx pyjwt 2>&1 + echo "=== Build complete ===" + ''; + + installPhase = '' + mkdir -p $out/bin $out/lib/pr-agent-server + cp run_server.py $out/lib/pr-agent-server/ + + cat > $out/bin/pr-agent-server << WRAPPER +#!${pkgs.runtimeShell} +export PATH=${pkgs.git}/bin:\$PATH +cd $out/lib/pr-agent-server +exec $out/venv/bin/python run_server.py +WRAPPER + chmod +x $out/bin/pr-agent-server + ''; + }; + }); +} diff --git a/generate_manifest.py b/generate_manifest.py new file mode 100644 index 0000000..51a6ece --- /dev/null +++ b/generate_manifest.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Generate GitHub App manifest URL for PR-Agent""" +import json +import base64 +import secrets + +# Generate webhook secret +webhook_secret = secrets.token_hex(20) +print(f"Webhook Secret: {webhook_secret}") + +manifest = { + "name": "pr-agent-auto-review", + "url": "https://github.com/asepharyana", + "hook_attributes": { + "url": f"http://45.127.35.244:3000/api/v1/github_webhooks", + "active": True + }, + "redirect_url": "http://45.127.35.244:3000/setup/callback", + "callback_urls": ["http://45.127.35.244:3000/setup/callback"], + "public": False, + "default_events": [ + "pull_request", + "issue_comment" + ], + "default_permissions": { + "pull_requests": "write", + "issues": "write", + "contents": "read", + "metadata": "read", + "checks": "write" + } +} + +# Encode manifest to base64 URL-safe +manifest_json = json.dumps(manifest) +manifest_b64 = base64.urlsafe_b64encode(manifest_json.encode()).decode() + +url = f"https://github.com/settings/apps/new?manifest={manifest_b64}" +print(f"\n{'='*60}") +print("MANIFEST URL (klik di browser GitHub asepharyana):") +print(f"{'='*60}") +print(url) +print(f"{'='*60}") + +# Save for later +with open("/opt/pr-agent-server/manifest.json", "w") as f: + json.dump(manifest, f, indent=2) + +with open("/opt/pr-agent-server/webhook_secret.txt", "w") as f: + f.write(webhook_secret) + +print(f"\nManifest saved to: /opt/pr-agent-server/manifest.json") +print(f"Webhook secret saved to: /opt/pr-agent-server/webhook_secret.txt") diff --git a/generate_manifest_domain.py b/generate_manifest_domain.py new file mode 100644 index 0000000..79ec1d2 --- /dev/null +++ b/generate_manifest_domain.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Generate GitHub App manifest with domain URL""" +import json +import base64 +import secrets + +webhook_secret = secrets.token_hex(20) +print(f"Webhook Secret: {webhook_secret}") + +manifest = { + "name": "pr-agent-auto-review", + "url": "https://github.com/asepharyana", + "hook_attributes": { + "url": "https://pr-agent.asepharyana.my.id/api/v1/github_webhooks", + "active": True + }, + "redirect_url": "https://pr-agent.asepharyana.my.id/setup/callback", + "callback_urls": ["https://pr-agent.asepharyana.my.id/setup/callback"], + "public": False, + "default_events": [ + "pull_request", + "issue_comment" + ], + "default_permissions": { + "pull_requests": "write", + "issues": "write", + "contents": "read", + "metadata": "read", + "checks": "write" + } +} + +manifest_json = json.dumps(manifest) +manifest_b64 = base64.urlsafe_b64encode(manifest_json.encode()).decode() + +print(f"\n{'='*60}") +print("BUAT APP BARU - Klik link ini di browser GitHub:") +print(f"{'='*60}") +print(f"https://github.com/settings/apps/new?manifest={manifest_b64}") +print(f"{'='*60}") + +# Save +with open("/opt/pr-agent-server/manifest_domain.json", "w") as f: + json.dump(manifest, f, indent=2) +with open("/opt/pr-agent-server/webhook_secret.txt", "w") as f: + f.write(webhook_secret) + +print(f"\nWebhook secret: {webhook_secret}") +print(f"Webhook URL: https://pr-agent.asepharyana.my.id/api/v1/github_webhooks") diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..0640c10 --- /dev/null +++ b/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "pr-agent-auto-review", + "url": "https://github.com/asepharyana", + "hook_attributes": { + "url": "http://45.127.35.244:3000/api/v1/github_webhooks", + "active": true + }, + "redirect_url": "http://45.127.35.244:3000/setup/callback", + "callback_urls": [ + "http://45.127.35.244:3000/setup/callback" + ], + "public": false, + "default_events": [ + "pull_request", + "issue_comment" + ], + "default_permissions": { + "pull_requests": "write", + "issues": "write", + "contents": "read", + "metadata": "read", + "checks": "write" + } +} \ No newline at end of file diff --git a/manifest_current.json b/manifest_current.json new file mode 100644 index 0000000..0640c10 --- /dev/null +++ b/manifest_current.json @@ -0,0 +1,24 @@ +{ + "name": "pr-agent-auto-review", + "url": "https://github.com/asepharyana", + "hook_attributes": { + "url": "http://45.127.35.244:3000/api/v1/github_webhooks", + "active": true + }, + "redirect_url": "http://45.127.35.244:3000/setup/callback", + "callback_urls": [ + "http://45.127.35.244:3000/setup/callback" + ], + "public": false, + "default_events": [ + "pull_request", + "issue_comment" + ], + "default_permissions": { + "pull_requests": "write", + "issues": "write", + "contents": "read", + "metadata": "read", + "checks": "write" + } +} \ No newline at end of file diff --git a/manifest_final.json b/manifest_final.json new file mode 100644 index 0000000..9bce6be --- /dev/null +++ b/manifest_final.json @@ -0,0 +1,24 @@ +{ + "name": "pr-agent-auto-review", + "url": "https://github.com/asepharyana", + "hook_attributes": { + "url": "https://pr-agent.asepharyana.my.id/api/v1/github_webhooks", + "active": true + }, + "redirect_url": "https://pr-agent.asepharyana.my.id/setup/callback", + "callback_urls": [ + "https://pr-agent.asepharyana.my.id/setup/callback" + ], + "public": false, + "default_events": [ + "pull_request", + "issue_comment" + ], + "default_permissions": { + "pull_requests": "write", + "issues": "write", + "contents": "read", + "metadata": "read", + "checks": "write" + } +} \ No newline at end of file diff --git a/run_server.py b/run_server.py new file mode 100644 index 0000000..25292f4 --- /dev/null +++ b/run_server.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""PR-Agent GitHub App + manifest callback server""" +import os, sys, json + +# Configurable paths (systemd Nix deployment keeps secrets outside the store) +APP_DIR = os.environ.get("PR_AGENT_APP_DIR", "/var/lib/pr-agent-server") +private_key_path = os.environ.get( + "PRIVATE_KEY_PATH", os.path.join(APP_DIR, "private-key.pem") +) +omni_key_path = os.environ.get( + "OMNIROUTE_KEY_PATH", os.path.join(APP_DIR, "omniroute_key") +) + +with open(private_key_path) as f: + private_key = f.read() + +os.environ["GITHUB__DEPLOYMENT_TYPE"] = "app" +os.environ["GITHUB__APP_ID"] = os.environ.get("GITHUB_APP_ID", "4319749") +os.environ["GITHUB__PRIVATE_KEY"] = private_key +os.environ["GITHUB__WEBHOOK_SECRET"] = os.environ.get("GITHUB_WEBHOOK_SECRET", "") + +with open(omni_key_path) as f: + omni_key = f.read().strip() +os.environ["OPENAI__API_BASE"] = os.environ.get( + "OPENAI_API_BASE", "https://omniroute.imrnes.team/v1" +) +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"]', +) +os.environ["CONFIG__CUSTOM_MODEL_MAX_TOKENS"] = os.environ.get( + "PR_AGENT_MAX_TOKENS", "128000" +) +os.environ["GITHUB_APP__PR_COMMANDS"] = os.environ.get( + "PR_AGENT_PR_COMMANDS", + '["/review --pr_reviewer.require_score_review=true --pr_reviewer.require_security_review=true","/describe","/improve"]', +) + +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 +import uvicorn +import httpx + +from starlette.middleware import Middleware +from starlette_context.middleware import RawContextMiddleware + +app = FastAPI(middleware=[Middleware(RawContextMiddleware)]) +app.include_router(pr_router) + + +@app.get("/setup/callback") +@app.post("/setup/callback") +async def callback(request: Request): + params = dict(request.query_params) + if "code" in params: + code = params["code"] + async with httpx.AsyncClient() as client: + resp = await client.post( + f"https://api.github.com/app-manifests/{code}/conversions", + headers={"Accept": "application/vnd.github.v3+json"}, + ) + if resp.status_code == 201: + data = resp.json() + creds = { + "app_id": data.get("id"), + "pem": data.get("pem"), + "webhook_secret": data.get("webhook_secret"), + "slug": data.get("slug"), + } + with open(os.path.join(APP_DIR, "credentials_callback.json"), "w") as f: + json.dump(creds, f, indent=2) + return { + "status": "success", + "app_id": creds["app_id"], + "slug": creds["slug"], + } + return {"status": "ok", "message": "callback received"} + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", "3000")) + print(f"PR-Agent GitHub App server starting...") + 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" Port: {port}") + uvicorn.run(app, host="0.0.0.0", port=port, log_level="info") diff --git a/setup_all.py b/setup_all.py new file mode 100644 index 0000000..71861ab --- /dev/null +++ b/setup_all.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +PR-Agent GitHub App - Complete Setup & Server +Generates manifest URL, starts webhook server, handles callback +""" +import json, base64, secrets, os, sys, threading +from pathlib import Path + +WEBHOOK_SECRET = secrets.token_hex(20) +BASE_DIR = Path("/opt/pr-agent-server") +BASE_DIR.mkdir(parents=True, exist_ok=True) + +# ── 1. Generate Manifest ── +manifest = { + "name": "pr-agent-auto", + "url": "https://github.com/asepharyana", + "hook_attributes": { + "url": "http://45.127.35.244:3000/api/v1/github_webhooks", + "active": True + }, + "redirect_url": "http://45.127.35.244:3000/setup/callback", + "callback_urls": ["http://45.127.35.244:3000/setup/callback"], + "public": False, + "default_events": ["pull_request", "issue_comment"], + "default_permissions": { + "pull_requests": "write", + "issues": "write", + "contents": "read", + "metadata": "read", + "checks": "write" + } +} + +manifest_b64 = base64.urlsafe_b64encode(json.dumps(manifest).encode()).decode() +manifest_url = f"https://github.com/settings/apps/new?manifest={manifest_b64}" + +# ── 2. Save configs ── +with open(BASE_DIR / "manifest.json", "w") as f: + json.dump(manifest, f, indent=2) +with open(BASE_DIR / "webhook_secret.txt", "w") as f: + f.write(WEBHOOK_SECRET) +with open(BASE_DIR / "manifest_url.txt", "w") as f: + f.write(manifest_url) + +print(f""" +╔══════════════════════════════════════════════════╗ +║ PR-Agent GitHub App Setup ║ +╠══════════════════════════════════════════════════╣ +║ ║ +║ Webhook Secret: {WEBHOOK_SECRET[:20]}... ║ +║ ║ +║ MANIFEST URL: ║ +║ {manifest_url[:60]}... ║ +║ ║ +║ Buka URL di atas di browser GitHub ║ +║ asepharyana, klik Create, lalu kirim ║ +║ App ID + Private Key ke sini. ║ +║ ║ +╚══════════════════════════════════════════════════╝ +""") + +# ── 3. Create .secrets.toml for PR-Agent ── +KEY = os.environ.get("OMNIROUTE_API_KEY", "") +secrets_toml = f"""[openai] +key = "{KEY}" +api_base = "https://omniroute.imrnes.team/v1" + +[github] +deployment_type = "app" +# Will be filled after app creation: +# app_id = 123456 +# private_key = """ +# webhook_secret = "{WEBHOOK_SECRET}" +""" + +with open(BASE_DIR / ".secrets.toml", "w") as f: + f.write(secrets_toml) + +# ── 4. Create PR-Agent config ── +config_toml = """[config] +model = "claude-opus-4-8" +fallback_models = ["auto/best-coding", "auto/claude-sonnet"] +custom_model_max_tokens = 128000 +git_provider = "github" +publish_output = true +verbosity_level = 0 + +[github_app] +pr_commands = ["/describe", "/review", "/improve"] +handle_push_trigger = true +push_commands = ["/describe", "/review"] + +[pr_reviewer] +num_max_findings = 5 +require_tests_review = true +require_security_review = true + +[pr_description] +enable_pr_diagram = true +use_bullet_points = true + +[pr_code_suggestions] +num_code_suggestions_per_chunk = 4 +""" + +with open(BASE_DIR / "configuration.toml", "w") as f: + f.write(config_toml) + +# ── 5. Create systemd service file ── +app_dir = os.path.expanduser("~/hermes-agent/.venv/lib/python3.12/site-packages") +service = f"""[Unit] +Description=PR-Agent GitHub App Webhook Server +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory={BASE_DIR} +Environment="PYTHONPATH={app_dir}" +Environment="OMNIROUTE_API_KEY={KEY}" +Environment="OPENAI_KEY={KEY}" +Environment="OPENAI_API_BASE=https://omniroute.imrnes.team/v1" +Environment="ANTHROPIC_API_KEY={KEY}" +Environment="ANTHROPIC_API_BASE=https://omniroute.imrnes.team/v1" +Environment="PORT=3000" +ExecStart={sys.executable} -c "from pr_agent.servers.github_app import app; import uvicorn; uvicorn.run(app, host='0.0.0.0', port=3000, log_level='info')" +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +""" + +with open(BASE_DIR / "pr-agent.service", "w") as f: + f.write(service) + +print(f" Config files created in {BASE_DIR}") +print(f" Run: cp {BASE_DIR}/pr-agent.service /etc/systemd/system/") +print(f" Then: systemctl daemon-reload && systemctl enable --now pr-agent") diff --git a/setup_app.py b/setup_app.py new file mode 100644 index 0000000..57d2a37 --- /dev/null +++ b/setup_app.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +PR-Agent GitHub App Setup Helper +Creates the GitHub App manifest and prepares the server configuration. +""" +import json +import base64 +import os +import secrets + +# ============================================================ +# CONFIGURATION +# ============================================================ +APP_NAME = "pr-agent-auto" +APP_SLUG = "pr-agent-auto" +DESCRIPTION = "Automated PR review and merge bot powered by AI" +HOME_URL = "https://github.com/asepharyana" +PUBLIC_IP = "45.127.35.244" +PORT = 3000 +WEBHOOK_URL = f"http://{PUBLIC_IP}:{PORT}/api/v1/github_webhooks" +REDIRECT_URL = f"http://{PUBLIC_IP}:{PORT}/app-setup-complete" +CALLBACK_URLS = [f"http://{PUBLIC_IP}:{PORT}/callback"] + +# Generate webhook secret +WEBHOOK_SECRET = secrets.token_hex(20) + +# ============================================================ +# CREATE MANIFEST +# ============================================================ +manifest = { + "name": APP_NAME, + "slug": APP_SLUG, + "description": DESCRIPTION, + "url": HOME_URL, + "hook_attributes": { + "url": WEBHOOK_URL, + "active": True + }, + "redirect_url": REDIRECT_URL, + "callback_urls": CALLBACK_URLS, + "public": False, + "default_events": [ + "pull_request", + "issue_comment", + "push" + ], + "default_permissions": { + "pull_requests": "write", + "issues": "write", + "metadata": "read", + "contents": "read", + "checks": "write", + "emails": "read" + } +} + +# Save manifest +os.makedirs("/opt/pr-agent-server", exist_ok=True) +manifest_path = "/opt/pr-agent-server/manifest.json" + +with open(manifest_path, "w") as f: + json.dump(manifest, f, indent=2) + +# Create the URL +manifest_b64 = base64.b64encode(json.dumps(manifest).encode()).decode() +manifest_url = f"https://github.com/settings/apps/new?manifest={manifest_b64}" + +print("=" * 60) +print("PR-Agent GITHUB APP SETUP") +print("=" * 60) +print(f"\n📋 App Name: {APP_NAME}") +print(f"🌐 Webhook URL: {WEBHOOK_URL}") +print(f"🔑 Webhook Secret: {WEBHOOK_SECRET}") +print(f"\n{'=' * 60}") +print("STEP 1: Click this URL to create the GitHub App:") +print(f"{'=' * 60}") +print(f"\n{manifest_url}\n") +print(f"{'=' * 60}") +print("STEP 2: After clicking 'Create GitHub App', you'll be redirected.") +print(" Save the App ID, Private Key, and Webhook Secret shown.") +print(f"{'=' * 60}") + +# Save vars for later use +env_file = "/opt/pr-agent-server/.env" +with open(env_file, "w") as f: + f.write(f"WEBHOOK_SECRET={WEBHOOK_SECRET}\n") + f.write(f"PORT={PORT}\n") + f.write("# After GitHub App creation, add:\n") + f.write("# APP_ID=\n") + f.write("# PRIVATE_KEY_PATH=/opt/pr-agent-server/private-key.pem\n") + f.write(f"# GITHUB_APP_NAME={APP_NAME}\n") + +print(f"\n📁 Config saved to: {manifest_path}") +print(f"📁 Env file: {env_file}") +print(f"\nWebhook Secret (save this!): {WEBHOOK_SECRET}") diff --git a/start_server.py b/start_server.py new file mode 100644 index 0000000..fc38118 --- /dev/null +++ b/start_server.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""PR-Agent GitHub Webhook Server - Start Script""" +import os +import sys + +# Add the pr-agent package to path +sys.path.insert(0, os.path.expanduser("~/hermes-agent/.venv/lib/python3.12/site-packages")) + +from pr_agent.servers.github_app import app +import uvicorn + +if __name__ == '__main__': + port = int(os.environ.get("PORT", "3000")) + print(f"Starting PR-Agent GitHub App server on 0.0.0.0:{port}") + uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")