From 570b5b87075f6fdc88eaad43daf552dedf612990 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 21 Sep 2026 14:49:45 +0700 Subject: [PATCH] chore(cleanup): remove retired Python/Nix pr_agent server entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .github/workflows/deploy.yml | 71 ++-- .../workflows/flakehub-publish-rolling.yaml | 20 -- README.md | 57 ++-- flake.lock | 61 ---- flake.nix | 88 ----- scripts/generate_manifest.py | 53 --- scripts/generate_manifest_domain.py | 49 --- scripts/setup_all.py | 139 -------- scripts/setup_app.py | 95 ------ src/auto_merge_bot.py | 294 ---------------- src/callback_server.py | 54 --- src/health-check.py | 230 ------------- src/run_server.py | 321 ------------------ src/start_server.py | 15 - src/sync-key.py | 90 ----- src/trivial_merge.py | 136 -------- 16 files changed, 67 insertions(+), 1706 deletions(-) delete mode 100644 .github/workflows/flakehub-publish-rolling.yaml delete mode 100644 flake.lock delete mode 100644 flake.nix delete mode 100644 scripts/generate_manifest.py delete mode 100644 scripts/generate_manifest_domain.py delete mode 100644 scripts/setup_all.py delete mode 100644 scripts/setup_app.py delete mode 100644 src/auto_merge_bot.py delete mode 100644 src/callback_server.py delete mode 100644 src/health-check.py delete mode 100644 src/run_server.py delete mode 100644 src/start_server.py delete mode 100644 src/sync-key.py delete mode 100644 src/trivial_merge.py diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3e3b06d..d95fb57 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Build & Deploy (Nix) +name: Build & Deploy (Bun) on: push: @@ -18,46 +18,31 @@ env: VPS_USER: ${{ secrets.VPS_USER }} 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: - needs: syntax-check runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 - submodules: false - - name: Install Nix - uses: DeterminateSystems/nix-installer-action@v22 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 with: - determinate: false - extra-conf: | - sandbox = false - accept-flake-config = true + bun-version: 1.3.14 - - name: Cache Nix - uses: DeterminateSystems/magic-nix-cache-action@v14 - with: - use-flakehub: false - - - name: Build pr-agent-server - id: build + - name: Typecheck + tests + working-directory: server run: | - nix build .#default --impure --option sandbox false --print-build-logs - STORE_PATH=$(readlink result) - echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT" - echo "Build OK: $STORE_PATH" + bun install --frozen-lockfile + bunx tsc --noEmit + bun test + + - 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 env: @@ -70,18 +55,24 @@ jobs: 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 - - name: Deploy pr-agent-server to VPS + - name: Deploy to VPS run: | - STORE_PATH="${{ steps.build.outputs.store-path }}" - echo "=== Copying pr-agent-server: $STORE_PATH ===" - nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH" + echo "=== Uploading pr-agent-bun binary ===" + scp server/pr-agent-bun "$VPS_USER@$VPS_HOST:/tmp/pr-agent-bun.new" - echo "=== Updating profile ===" - 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'" - - echo "=== Restarting service ===" - ssh "$VPS_USER@$VPS_HOST" "sudo systemctl daemon-reload && sudo systemctl restart pr-agent-server && sleep 3 && sudo systemctl is-active pr-agent-server" - echo "✅ pr-agent-server deployed" + echo "=== Staging swap + restart ===" + ssh "$VPS_USER@$VPS_HOST" ' + set -e + sudo cp /opt/pr-agent-server/bin/pr-agent-bun /opt/pr-agent-server/bin/pr-agent-bun.prev + sudo mv /tmp/pr-agent-bun.new /opt/pr-agent-server/bin/pr-agent-bun + 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: # Bersihkan sampah Nix di VPS SETELAH deploy: hapus generasi profile lama diff --git a/.github/workflows/flakehub-publish-rolling.yaml b/.github/workflows/flakehub-publish-rolling.yaml deleted file mode 100644 index 34e96e7..0000000 --- a/.github/workflows/flakehub-publish-rolling.yaml +++ /dev/null @@ -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 diff --git a/README.md b/README.md index 6c95022..c4d6b8c 100644 --- a/README.md +++ b/README.md @@ -45,27 +45,35 @@ pr-agent-server/ ## Development ### Prerequisites -- Nix (for builds) -- Python 3.12+ +- Bun 1.3.14+ (runtime + build) - GitHub App credentials (App ID, private key, webhook secret) -- BWS (Bitwarden Secrets Manager) access token +- 9router/OpenAI-compatible key for the LLM ### Local testing ```bash -# Syntax check -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 +cd server +bun install +bunx tsc --noEmit # typecheck +bun test # unit tests (16 tests) +bun src/cli.ts --tool review --repo / --pr --no-publish +``` -# Nix build -nix build .#default +### Run server (after setting up secrets) -# Run server (after setting up secrets) -export BWS_ACCESS_TOKEN="" -nix run .#pr-agent-server-sync-key # syncs the router key -nix run .#pr-agent-server # starts uvicorn on :3000 +```bash +# Secrets are resolved at startup: PR_AGENT_APP_ID, private key path, +# omniroute key file (see src/config.ts + src/secrets.ts) +cd server +bun src/index.ts # starts on PORT (default 4023) +``` -# Health check -nix run .#pr-agent-server-health-check +### Test tools end-to-end (real GitHub + LLM) + +```bash +bun e2e.ts --repo asepharyana/nextjs-template --pr 19 --publish +bun src/cli.ts --tool describe --repo / --pr +bun src/cli.ts --tool improve --repo / --pr ``` ## Deployment @@ -74,11 +82,16 @@ Deploy is fully automated via GitHub Actions on push to `main`: ```yaml # .github/workflows/deploy.yml -1. syntax-check → python3 py_compile all modules -2. build-and-deploy → nix build → SSH to VPS → update profile → restart service -3. cleanup → nix-gc-vps.sh (with profile link repair) +1. build-and-deploy → bun install → typecheck → tests → bun build --compile + → scp binary to VPS → swap /opt/pr-agent-server/bin/pr-agent-bun + → 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: - `VPS_HOST` — VPS IP address - `VPS_USER` — SSH user @@ -87,15 +100,17 @@ Secrets required in GitHub Actions: ## Ops -- **Health watchdog**: cron `pr-agent-health-watchdog` (every 10 min) → `~/.hermes/scripts/pr-agent-health-check.sh` → Nix binary `pr-agent-health-check` -- **Key auto-sync**: systemd `ExecStartPre=/usr/local/bin/bws-exec pr-agent -- /bin/pr-agent-sync-key` +- **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` +- **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` -- **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 -## 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 diff --git a/flake.lock b/flake.lock deleted file mode 100644 index 617b2f3..0000000 --- a/flake.lock +++ /dev/null @@ -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 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index 0e4bcd0..0000000 --- a/flake.nix +++ /dev/null @@ -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 - ''; - }; - }); -} diff --git a/scripts/generate_manifest.py b/scripts/generate_manifest.py deleted file mode 100644 index 3ed9a8f..0000000 --- a/scripts/generate_manifest.py +++ /dev/null @@ -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") diff --git a/scripts/generate_manifest_domain.py b/scripts/generate_manifest_domain.py deleted file mode 100644 index 79ec1d2..0000000 --- a/scripts/generate_manifest_domain.py +++ /dev/null @@ -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") diff --git a/scripts/setup_all.py b/scripts/setup_all.py deleted file mode 100644 index 72298c9..0000000 --- a/scripts/setup_all.py +++ /dev/null @@ -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 = "" -# 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") diff --git a/scripts/setup_app.py b/scripts/setup_app.py deleted file mode 100644 index b744d0d..0000000 --- a/scripts/setup_app.py +++ /dev/null @@ -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=\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/src/auto_merge_bot.py b/src/auto_merge_bot.py deleted file mode 100644 index 870beef..0000000 --- a/src/auto_merge_bot.py +++ /dev/null @@ -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() diff --git a/src/callback_server.py b/src/callback_server.py deleted file mode 100644 index 2bd2487..0000000 --- a/src/callback_server.py +++ /dev/null @@ -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") diff --git a/src/health-check.py b/src/health-check.py deleted file mode 100644 index 0228385..0000000 --- a/src/health-check.py +++ /dev/null @@ -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()) diff --git a/src/run_server.py b/src/run_server.py deleted file mode 100644 index e5df640..0000000 --- a/src/run_server.py +++ /dev/null @@ -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") diff --git a/src/start_server.py b/src/start_server.py deleted file mode 100644 index f5d132b..0000000 --- a/src/start_server.py +++ /dev/null @@ -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") diff --git a/src/sync-key.py b/src/sync-key.py deleted file mode 100644 index 12159f8..0000000 --- a/src/sync-key.py +++ /dev/null @@ -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()) diff --git a/src/trivial_merge.py b/src/trivial_merge.py deleted file mode 100644 index a44c898..0000000 --- a/src/trivial_merge.py +++ /dev/null @@ -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','')}"