chore(cleanup): remove retired Python/Nix pr_agent server entirely

- delete src/*.py (run_server, start_server, callback_server, health-check,
  auto_merge_bot, trivial_merge, sync-key) — Python pr_agent retired
- delete scripts/{setup_app,setup_all,generate_manifest,generate_manifest_domain}.py
- delete flake.nix + flake.lock + flakehub-publish-rolling.yaml — Nix build retired
- deploy.yml: Nix/Python CI → Bun CI (setup-bun, typecheck, tests,
  bun build --compile → scp binary → swap /opt/.../pr-agent-bun →
  restart pr-agent-bun.service → health check :4023)
- README: document Bun era; legacy Python/Nix section
- pr-agent-bun.service is the sole production server (port 4023)
This commit is contained in:
asepharyana
2026-09-21 14:49:45 +07:00
parent 608b52a0e5
commit 570b5b8707
16 changed files with 67 additions and 1706 deletions
+31 -40
View File
@@ -1,4 +1,4 @@
name: Build & Deploy (Nix) name: Build & Deploy (Bun)
on: on:
push: push:
@@ -18,46 +18,31 @@ env:
VPS_USER: ${{ secrets.VPS_USER }} VPS_USER: ${{ secrets.VPS_USER }}
jobs: jobs:
syntax-check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Python syntax check
run: |
python3 -m py_compile src/run_server.py src/auto_merge_bot.py src/callback_server.py scripts/setup_app.py scripts/setup_all.py scripts/generate_manifest.py scripts/generate_manifest_domain.py src/start_server.py src/sync-key.py src/health-check.py src/trivial_merge.py
build-and-deploy: build-and-deploy:
needs: syntax-check
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: false
- name: Install Nix - name: Setup Bun
uses: DeterminateSystems/nix-installer-action@v22 uses: oven-sh/setup-bun@v2
with: with:
determinate: false bun-version: 1.3.14
extra-conf: |
sandbox = false
accept-flake-config = true
- name: Cache Nix - name: Typecheck + tests
uses: DeterminateSystems/magic-nix-cache-action@v14 working-directory: server
with:
use-flakehub: false
- name: Build pr-agent-server
id: build
run: | run: |
nix build .#default --impure --option sandbox false --print-build-logs bun install --frozen-lockfile
STORE_PATH=$(readlink result) bunx tsc --noEmit
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT" bun test
echo "Build OK: $STORE_PATH"
- name: Build single binary
working-directory: server
run: |
bun build --compile src/index.ts --outfile pr-agent-bun
ls -lh pr-agent-bun
- name: Setup SSH key - name: Setup SSH key
env: env:
@@ -70,18 +55,24 @@ jobs:
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; } ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy pr-agent-server to VPS - name: Deploy to VPS
run: | run: |
STORE_PATH="${{ steps.build.outputs.store-path }}" echo "=== Uploading pr-agent-bun binary ==="
echo "=== Copying pr-agent-server: $STORE_PATH ===" scp server/pr-agent-bun "$VPS_USER@$VPS_HOST:/tmp/pr-agent-bun.new"
nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
echo "=== Updating profile ===" echo "=== Staging swap + restart ==="
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-env --profile /nix/var/nix/profiles/pr-agent-server --set '$STORE_PATH'" ssh "$VPS_USER@$VPS_HOST" '
set -e
echo "=== Restarting service ===" sudo cp /opt/pr-agent-server/bin/pr-agent-bun /opt/pr-agent-server/bin/pr-agent-bun.prev
ssh "$VPS_USER@$VPS_HOST" "sudo systemctl daemon-reload && sudo systemctl restart pr-agent-server && sleep 3 && sudo systemctl is-active pr-agent-server" sudo mv /tmp/pr-agent-bun.new /opt/pr-agent-server/bin/pr-agent-bun
echo "✅ pr-agent-server deployed" sudo chown root:root /opt/pr-agent-server/bin/pr-agent-bun
sudo chmod 755 /opt/pr-agent-server/bin/pr-agent-bun
sudo systemctl restart pr-agent-bun.service
sleep 3
systemctl is-active pr-agent-bun.service
curl -fsS http://127.0.0.1:4023/health
'
echo "✅ pr-agent-bun deployed"
cleanup: cleanup:
# Bersihkan sampah Nix di VPS SETELAH deploy: hapus generasi profile lama # Bersihkan sampah Nix di VPS SETELAH deploy: hapus generasi profile lama
@@ -1,20 +0,0 @@
name: Publish to FlakeHub
on:
push:
branches: [main, master]
workflow_dispatch:
jobs:
flakehub-publish:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: DeterminateSystems/determinate-nix-action@main
- uses: DeterminateSystems/flakehub-push@main
with:
visibility: public
rolling: true
+36 -21
View File
@@ -45,27 +45,35 @@ pr-agent-server/
## Development ## Development
### Prerequisites ### Prerequisites
- Nix (for builds) - Bun 1.3.14+ (runtime + build)
- Python 3.12+
- GitHub App credentials (App ID, private key, webhook secret) - GitHub App credentials (App ID, private key, webhook secret)
- BWS (Bitwarden Secrets Manager) access token - 9router/OpenAI-compatible key for the LLM
### Local testing ### Local testing
```bash ```bash
# Syntax check cd server
python3 -m py_compile src/run_server.py src/auto_merge_bot.py src/health-check.py src/sync-key.py src/trivial_merge.py src/callback_server.py bun install
bunx tsc --noEmit # typecheck
bun test # unit tests (16 tests)
bun src/cli.ts --tool review --repo <owner>/<repo> --pr <n> --no-publish
```
# Nix build ### Run server (after setting up secrets)
nix build .#default
# Run server (after setting up secrets) ```bash
export BWS_ACCESS_TOKEN="<your-bws-token>" # Secrets are resolved at startup: PR_AGENT_APP_ID, private key path,
nix run .#pr-agent-server-sync-key # syncs the router key # omniroute key file (see src/config.ts + src/secrets.ts)
nix run .#pr-agent-server # starts uvicorn on :3000 cd server
bun src/index.ts # starts on PORT (default 4023)
```
# Health check ### Test tools end-to-end (real GitHub + LLM)
nix run .#pr-agent-server-health-check
```bash
bun e2e.ts --repo asepharyana/nextjs-template --pr 19 --publish
bun src/cli.ts --tool describe --repo <owner>/<repo> --pr <n>
bun src/cli.ts --tool improve --repo <owner>/<repo> --pr <n>
``` ```
## Deployment ## Deployment
@@ -74,11 +82,16 @@ Deploy is fully automated via GitHub Actions on push to `main`:
```yaml ```yaml
# .github/workflows/deploy.yml # .github/workflows/deploy.yml
1. syntax-check → python3 py_compile all modules 1. build-and-deploy → bun install → typecheck → tests → bun build --compile
2. build-and-deploy → nix build → SSH to VPS → update profile → restart service → scp binary to VPS → swap /opt/pr-agent-server/bin/pr-agent-bun
3. cleanup → nix-gc-vps.sh (with profile link repair) → restart pr-agent-bun.service → health check on :4023
2. cleanup → nix-gc-vps.sh (cleans legacy Nix store entries)
``` ```
The production server is a single compiled binary
(`/opt/pr-agent-server/bin/pr-agent-bun`) running as a systemd service
(`pr-agent-bun.service`, port 4023, secrets via `bws-exec pr-agent`).
Secrets required in GitHub Actions: Secrets required in GitHub Actions:
- `VPS_HOST` — VPS IP address - `VPS_HOST` — VPS IP address
- `VPS_USER` — SSH user - `VPS_USER` — SSH user
@@ -87,15 +100,17 @@ Secrets required in GitHub Actions:
## Ops ## Ops
- **Health watchdog**: cron `pr-agent-health-watchdog` (every 10 min) → `~/.hermes/scripts/pr-agent-health-check.sh` → Nix binary `pr-agent-health-check` - **Health watchdog**: cron `pr-agent-health-watchdog` (every 10 min) → `~/.hermes/scripts/pr-agent-health-check.sh` → curl `http://127.0.0.1:4023/health`
- **Key auto-sync**: systemd `ExecStartPre=/usr/local/bin/bws-exec pr-agent -- <profile>/bin/pr-agent-sync-key` - **Secrets**: systemd `ExecStart=/usr/local/bin/bws-exec pr-agent env PORT=4023 /opt/pr-agent-server/bin/pr-agent-bun`
- **Prometheus**: `GET /api/metrics` → `pr_agent_requests_total`, `pr_agent_model_failures` - **Prometheus**: `GET /api/metrics` → `pr_agent_requests_total`, `pr_agent_model_failures`
- **Analytics**: `GET /api/analytics` → JSON summary (unwrap `"record"` field) - **Analytics**: `GET /api/analytics` → JSON summary (legacy `pr-agent.*.log` + `pr-agent.bun.jsonl`)
- **Discord**: `POST /api/v1/notify_review` → pr-agent-ops webhook - **Discord**: `POST /api/v1/notify_review` → pr-agent-ops webhook
## Nix Profile Integrity ## Legacy (Python/Nix — retired 2026-09-21)
⚠️ See the `devops/pr-agent-deployment` skill for troubleshooting broken `-link` profile symlinks after `nix store gc`. The GC script (`/usr/local/bin/nix-gc-vps.sh`) now includes a repair step. The original Python `pr_agent` server (FastAPI + Nix build, port 4002) is fully
retired: systemd unit deleted, venv removed, `src/*.py` + `scripts/setup_*` +
flake removed from the repo. The Bun binary replaced it end-to-end.
## License ## License
Generated
-61
View File
@@ -1,61 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1785301185,
"narHash": "sha256-eoS3KQTO0aPWXZvIaRbRAzSSHW3l5wdMFXtT1ISfoKA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9bc02893134c733dd85de46ee4fb2fac696b5529",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
-88
View File
@@ -1,88 +0,0 @@
{
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.eachSystem [ "x86_64-linux" ] (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 ];
buildInputs = [ pkgs.stdenv.cc.cc.lib ];
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
# Copy server modules
cp src/run_server.py $out/lib/pr-agent-server/
cp src/sync-key.py $out/lib/pr-agent-server/
cp src/health-check.py $out/lib/pr-agent-server/
cp src/trivial_merge.py $out/lib/pr-agent-server/
cp src/auto_merge_bot.py $out/lib/pr-agent-server/
cp src/callback_server.py $out/lib/pr-agent-server/
# Wrapper: pr-agent-server (main FastAPI webhook server)
cat > $out/bin/pr-agent-server << WRAPPER
#!${pkgs.runtimeShell}
export PATH=${pkgs.git}/bin:$PATH
export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH
cd $out/lib/pr-agent-server
exec $out/venv/bin/python run_server.py
WRAPPER
chmod +x $out/bin/pr-agent-server
# Wrapper: pr-agent-sync-key (BWS key sync)
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
# Wrapper: pr-agent-health-check (model health watchdog)
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
# Wrapper: pr-agent-auto-merge (merge worker)
cat > $out/bin/pr-agent-auto-merge << WRAPPER4
#!${pkgs.runtimeShell}
export PATH=${pkgs.git}/bin:$PATH
export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH
cd $out/lib/pr-agent-server
exec $out/venv/bin/python auto_merge_bot.py
WRAPPER4
chmod +x $out/bin/pr-agent-auto-merge
'';
};
});
}
-53
View File
@@ -1,53 +0,0 @@
#!/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"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"
}
}
# 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")
-49
View File
@@ -1,49 +0,0 @@
#!/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")
-139
View File
@@ -1,139 +0,0 @@
#!/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": "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_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 = "<paste PEM here>"
# 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 = "openai/claude-opus-5"
fallback_models = ["openai/claude-sonnet-5", "openai/claude-haiku-4-5-20251001", "openai/ATLAS", "openai/gemini", "openai/text", "openai/deepseek-v4-flash-free"]
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")
-95
View File
@@ -1,95 +0,0 @@
#!/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 = 4002
WEBHOOK_URL = "https://pr-agent.asepharyana.my.id/api/v1/github_webhooks"
REDIRECT_URL = "https://pr-agent.asepharyana.my.id/app-setup-complete"
CALLBACK_URLS = ["https://pr-agent.asepharyana.my.id/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=<your-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}")
-294
View File
@@ -1,294 +0,0 @@
#!/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 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:4023/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
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)
# ── 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...")
# 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()
-54
View File
@@ -1,54 +0,0 @@
#!/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")
-230
View File
@@ -1,230 +0,0 @@
#!/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.
Key resolution order (no hardcoding):
1. On-disk key file maintained by sync-key.py (source of truth for the
running server — always current after every service start/restart).
2. BWS_ACCESS_TOKEN env var (shell wrapper or gateway-injected).
3. /etc/bws-token file → bws secret get (with sudo fallback for
non-root processes).
"""
import os, sys, json, hashlib, subprocess
from pathlib import Path
# Configurable paths — no hardcoding; everything reads from env or known
# locations that the Nix build / systemd unit define.
BWS_SECRET_ID = os.environ.get(
"BWS_ROUTER_KEY_SECRET_ID", "2aef2194-971d-4dae-99dd-b49a0041f97c"
)
ROUTER_BASE = os.environ.get(
"ROUTER_BASE_URL", "https://9router.asepharyana.my.id/v1"
)
PRIMARY = os.environ.get("HEALTH_CHECK_PRIMARY_MODEL", "openai/claude-opus-5")
FALLBACKS = os.environ.get(
"HEALTH_CHECK_FALLBACK_MODELS",
"openai/claude-sonnet-5,openai/claude-haiku-4-5-20251001,openai/ATLAS,openai/gemini,openai/text,openai/deepseek-v4-flash-free"
).split(",")
# 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 = int(os.environ.get("HEALTH_CHECK_HTTP_TIMEOUT", "150"))
CONSECUTIVE_FAIL_FILE = Path(
os.environ.get("HEALTH_CHECK_FAIL_COUNT_FILE", "/tmp/pr-agent-health-fail-count")
)
# ── key resolution ──────────────────────────────────────────────────────────
# On-disk key file — maintained by sync-key.py (runs on every service start
# via systemd ExecStartPre). This is the SAME key the server uses, always
# current, no BWS dependency.
APP_DIR = Path(os.environ.get("PR_AGENT_APP_DIR", "/var/lib/pr-agent-server"))
KEYFILE = APP_DIR / "omniroute_key"
def _read_key_from_disk() -> str:
"""Read the router key from the on-disk file maintained by sync-key.py.
This is the primary source — always current after service start.
File is pr-agent:pr-agent 0600, so non-root processes use sudo."""
try:
if KEYFILE.is_file():
key = KEYFILE.read_text().strip()
if len(key) >= 10:
return key
except (PermissionError, OSError):
pass
# Fallback: sudo (works when user has NOPASSWD sudo or bws group)
try:
r = subprocess.run(
["sudo", "-n", "cat", str(KEYFILE)],
capture_output=True, text=True, timeout=10,
)
if r.returncode == 0:
key = r.stdout.strip()
if len(key) >= 10:
return key
except Exception:
pass
return ""
def _read_token() -> str:
"""Read BWS access 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)."""
# Try direct read first (works when gateway has bws group)
for path in (Path("/etc/bws-token"),):
try:
if path.is_file():
return path.read_text().strip()
except PermissionError:
pass
# Fallback: sudo (works when user has NOPASSWD sudo)
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 _fetch_key_from_bws() -> str:
"""Fetch the router key from BWS via the bws CLI."""
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.
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 ""
def get_key() -> str:
"""Resolve the router API key. Order: on-disk file → BWS CLI.
The on-disk file is always current (sync-key runs on every service start)
and has zero external dependencies — preferred path for the health check."""
# 1. On-disk file (fast, no subprocess, no BWS dependency)
key = _read_key_from_disk()
if key:
return key
# 2. BWS CLI fallback (for edge cases where the file is missing/stale)
key = _fetch_key_from_bws()
return key
# ── 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-5') 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 resolve router key "
"(on-disk file missing and 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())
-321
View File
@@ -1,321 +0,0 @@
#!/usr/bin/env python3
"""PR-Agent GitHub App + manifest callback server"""
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")
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()
# ── LLM provider routing ─────────────────────────────────────────
# 9router (and omniroute) reject ALL provider prefixes in the model name
# (e.g. `openai/claude-...`). Models are specified bare, e.g. `claude-opus-5`.
# litellm, however, routes a bare `claude-*` name to the **Anthropic native**
# provider — which needs its own base URL / key env vars. So we map the
# 9router endpoint + key onto `ANTHROPIC_API_BASE` / `ANTHROPIC_API_KEY`
# instead of the OpenAI-shaped `OPENAI__API_BASE` / `OPENAI__KEY` that litellm
# would only honour when the model carries an `openai/` prefix.
#
# Verified working:
# $ litellm.completion(model="claude-opus-5", ...) with the env below → 200
_llm_base = os.environ.get("OPENAI_API_BASE", "https://9router.asepharyana.my.id/v1")
os.environ["ANTHROPIC_API_BASE"] = _llm_base
os.environ["ANTHROPIC_API_KEY"] = omni_key
os.environ["OPENAI_API_BASE"] = _llm_base
os.environ["OPENAI_API_KEY"] = omni_key
os.environ["CONFIG__MODEL"] = os.environ.get("PR_AGENT_MODEL", "claude-opus-5")
os.environ["CONFIG__FALLBACK_MODELS"] = os.environ.get(
"PR_AGENT_FALLBACK_MODELS",
'["claude-sonnet-5","claude-haiku-4-5-20251001"]',
)
os.environ["CONFIG__CUSTOM_MODEL_MAX_TOKENS"] = os.environ.get(
"PR_AGENT_MAX_TOKENS", "128000"
)
# 9router (omniroute) is latency-tolerant but PR-Agent's default litellm
# timeout (~60-90s) is too short for 15k-token review prompts ->
# AnthropicException Timeout. Raise it so the full context fits.
# PR-Agent uses Dynaconf with prefix `PR_AGENT`; the `ai_timeout` field
# lives under the [config] section, so the env key is `PR_AGENT__AI_TIMEOUT`.
os.environ.setdefault("PR_AGENT__AI_TIMEOUT", "300")
os.environ.setdefault("LITELLM_REQUEST_TIMEOUT", "300")
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"]',
)
# 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 pr_agent.config_loader import get_settings
from fastapi import FastAPI, Request
import uvicorn
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)
# ── Override litellm request timeout ──────────────────────────────────────
# PR-Agent's Dynaconf config uses `envvar_prefix=False` (env vars disabled)
# and `.toml` files only, so PR_AGENT__AI_TIMEOUT does NOT work.
# We monkey-patch the loaded settings + litellm global so the long
# 15k-token review diffs via 9router get 300s instead of the default 120s.
try:
_s = get_settings()
_s.config["ai_timeout"] = 300
except Exception:
pass
import litellm as _litellm
# Force a higher request timeout — PR-Agent passes `timeout=120` (from
# configuration.toml `ai_timeout=120`) to litellm.completion, which overrides
# the global `litellm.request_timeout`. 9router/claude-opus-5 needs more
# time for 15k-token review diffs. We wrap acompletion() to clamp the kwarg.
from pr_agent.algo.ai_handlers import litellm_ai_handler as _laih
_orig_acompletion = _laih.acompletion
async def _patched_acompletion(*args, **kwargs):
t = kwargs.get("timeout")
if t is not None and float(t) <= 120:
kwargs["timeout"] = 600
return await _orig_acompletion(*args, **kwargs)
_laih.acompletion = _patched_acompletion
_litellm.request_timeout = 600
# ── 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):
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", "model": os.environ.get("PR_AGENT_MODEL", "")}
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-5')} 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")
-15
View File
@@ -1,15 +0,0 @@
#!/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", "4002"))
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")
-90
View File
@@ -1,90 +0,0 @@
#!/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
@@ -1,136 +0,0 @@
#!/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','')}"