From bc69998d8e27ed0c492d7a33232ecb8048ea75f0 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 21 Sep 2026 13:47:47 +0700 Subject: [PATCH] =?UTF-8?q?feat(server):=20production=20cut-over=20to=20Bu?= =?UTF-8?q?n=20=E2=80=94=20notify=5Freview,=20setup/callback,=20key=20reso?= =?UTF-8?q?lution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.ts: add POST /api/v1/notify_review (queue-worker Discord bridge), /setup/callback (GitHub App manifest conversion), error logging on review failure, PR_AGENT_APP_DIR-based private key resolution - config.ts: API key falls back to on-disk omniroute_key (same source as run_server.py) when no env key present — fixes 401 in systemd context - markdown.ts: don't hyperlink non-URL ticket values (N/A) - secrets.ts: fallback private key from ~/.hermes/keys + omni key ~/.hermes - pr-queue-worker.py / auto_merge_bot.py: notify_review default port 4002→4023 Deploy: pr-agent-bun.service (Bun binary, port 4023) replaces pr-agent-server.service (Python, port 4002, disabled). Caddy route updated in asepharyana/infra (proxy 4023). Verified live: webhook → review → claude-opus-5 → persistent GitHub comment published after Python shutdown. --- scripts/pr-queue-worker.py | 2 +- server/src/config.ts | 13 +++++- server/src/index.ts | 91 +++++++++++++++++++++++++++++++++----- server/src/markdown.ts | 2 +- server/src/secrets.ts | 26 ++++++++++- src/auto_merge_bot.py | 2 +- 6 files changed, 120 insertions(+), 16 deletions(-) diff --git a/scripts/pr-queue-worker.py b/scripts/pr-queue-worker.py index 2ba63c0..7716cf8 100644 --- a/scripts/pr-queue-worker.py +++ b/scripts/pr-queue-worker.py @@ -421,7 +421,7 @@ def merge_pr(token, repo_full, pr_num, sha): def post_discord_notification(repo_full, pr_num, status, summary="", score="", url=""): """Fire-and-forget Discord notification via pr-agent server internal endpoint.""" import httpx - notify_url = os.environ.get("PR_AGENT_NOTIFY_URL", "http://127.0.0.1:4002/api/v1/notify_review") + 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={ diff --git a/server/src/config.ts b/server/src/config.ts index 469b1c3..3697a9a 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -3,6 +3,8 @@ // review pipeline, with env-var overrides (same names as the Python server used, // minus the Dynaconf prefix dance — we read plain env vars). +import { readFileSync } from "node:fs"; + export interface Config { // model routing model: string; @@ -84,11 +86,20 @@ export function loadConfig(): Config { // Key resolution: the Python server used ANTHROPIC_API_KEY = omni key for // 9router. Prefer OMNIROUTE_API_KEY (verified live), fall back to - // ANTHROPIC_API_KEY then OPENAI_API_KEY. + // ANTHROPIC_API_KEY then OPENAI_API_KEY, then the on-disk omni key file + // (same source of truth as run_server.py: /var/lib/pr-agent-server/omniroute_key). + const appDir = env.PR_AGENT_APP_DIR || "/var/lib/pr-agent-server"; + let fileKey = ""; + try { + fileKey = readFileSync(`${appDir}/omniroute_key`, "utf8").trim(); + } catch { + // fall through + } const apiKey = env.OMNIROUTE_API_KEY || env.ANTHROPIC_API_KEY || env.OPENAI_API_KEY || + fileKey || ""; const baseUrl = env.OPENAI_API_BASE || "https://9router.asepharyana.my.id/v1"; diff --git a/server/src/index.ts b/server/src/index.ts index 8f9d4c2..c69af79 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -75,12 +75,13 @@ export async function handleWebhook( } // Fire and forget: dispatch review; respond fast (GitHub expects < 10s) - void runReview(env.cfg, owner, repo, pr.number, env.privateKeyPem) - .then(async (result) => { - if (env.analyticsDir) { - try { - const fsMod = await import("node:fs"); - fsMod.appendFileSync( + void runReview(env.cfg, owner, repo, pr.number, env.privateKeyPem) + .then(async (result) => { + console.log(`[webhook] review done for ${owner}/${repo}#${pr.number}: ${result.status}, model ${result.model}, md ${result.markdown.length} chars`); + if (env.analyticsDir) { + try { + const fsMod = await import("node:fs"); + fsMod.appendFileSync( `${env.analyticsDir}/pr-agent.bun.jsonl`, JSON.stringify({ time: new Date().toISOString(), @@ -111,6 +112,7 @@ export async function handleWebhook( } }) .catch((e) => { + console.error(`[webhook] review FAILED for ${owner}/${repo}#${pr.number}:`, e instanceof Error ? e.stack ?? e.message : e); if (env.discordAlertWebhookUrl) { void sendDiscord( env.discordAlertWebhookUrl, @@ -134,7 +136,7 @@ async function getHttpx() { return fetch; } -async function sendDiscord(webhook: string, content: string, title: string): Promise { +async function sendDiscord(webhook: string, content: string, title?: string): Promise { try { await fetch(webhook, { method: "POST", @@ -153,9 +155,12 @@ async function sendDiscord(webhook: string, content: string, title: string): Pro export function startServer(env?: Partial) { const cfg = env?.cfg ?? loadConfig(); - const privateKeyPem = - env?.privateKeyPem ?? - readPrivateKey(process.env.PRIVATE_KEY_PATH || "/opt/pr-agent-server/private-key.pem"); + const appDir = process.env.PR_AGENT_APP_DIR || "/var/lib/pr-agent-server"; + const privateKeyPem = + env?.privateKeyPem ?? + (readPrivateKey(process.env.PRIVATE_KEY_PATH || `${appDir}/private-key.pem`) || + readPrivateKey(`${appDir}/private-key.pem`) || + ""); const webhookSecret = env?.webhookSecret ?? process.env.GITHUB_WEBHOOK_SECRET ?? ""; const analyticsDir = @@ -181,6 +186,40 @@ export function startServer(env?: Partial) { if (url.pathname === "/health") { return Response.json({ status: "ok", model: cfg.model }); } + if (url.pathname === "/setup/callback") { + const code = url.searchParams.get("code") || ""; + if (code) { + try { + const resp = await fetch( + `https://api.github.com/app-manifests/${code}/conversions`, + { headers: { Accept: "application/vnd.github.v3+json" } }, + ); + if (resp.status === 201) { + const data = (await resp.json()) as { + id?: number; pem?: string; webhook_secret?: string; slug?: string; + }; + const creds = { + app_id: data.id, + pem: data.pem, + webhook_secret: data.webhook_secret, + slug: data.slug, + }; + await Bun.write( + `${appDir}/credentials_callback.json`, + JSON.stringify(creds, null, 2), + ); + return Response.json({ + status: "success", + app_id: creds.app_id, + slug: creds.slug, + }); + } + } catch (e) { + console.error("[callback] conversion failed:", e); + } + } + return Response.json({ status: "ok", message: "callback received" }); + } if (url.pathname === "/api/v1/github_webhooks" || url.pathname === "/") { if (req.method !== "POST") { return Response.json({ ok: true }); @@ -191,6 +230,33 @@ export function startServer(env?: Partial) { const result = await handleWebhook(fullEnv, body, sig, event); return Response.json(result.body, { status: result.status }); } + if (url.pathname === "/api/v1/notify_review") { + if (req.method !== "POST") { + return Response.json({ ok: false, error: "method not allowed" }, { status: 405 }); + } + try { + const body = (await req.json()) as { + repo?: string; pr?: string | number; status?: string; + summary?: string; score?: string; url?: string; + }; + const repo = body.repo || ""; + const prNum = String(body.pr ?? ""); + const status = body.status || "done"; + const summary = String(body.summary || ""); + const score = String(body.score || ""); + const url = String(body.url || ""); + const content = `Review ${status} for ${repo}#${prNum}` + + (score ? ` — score ${score}` : "") + `\n${summary}\n${url}`; + if (fullEnv.discordWebhookUrl) { + void sendDiscord(fullEnv.discordWebhookUrl, content).catch(() => {}); + } else { + console.log(`[notify] ${repo}#${prNum} ${status} ${score} ${url}`); + } + return Response.json({ ok: true }); + } catch (e) { + return Response.json({ ok: false, error: String(e) }, { status: 400 }); + } + } if (url.pathname === "/api/metrics") { return new Response(generateMetrics(), { headers: { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" }, @@ -224,4 +290,9 @@ function generateMetrics(): string { "# HELP pr_agent_requests_by_command PR-Agent events by command", "# TYPE pr_agent_requests_by_command counter", ].join("\n") + "\n"; +} + +// Entry point: `bun src/index.ts` (and the compiled binary) starts the server. +if (import.meta.main) { + startServer(); } \ No newline at end of file diff --git a/server/src/markdown.ts b/server/src/markdown.ts index 265c21f..2e628ff 100644 --- a/server/src/markdown.ts +++ b/server/src/markdown.ts @@ -180,7 +180,7 @@ function renderTicketCompliance( const compliance = tObj["overall_compliance_level"] || tObj["ticket_compliance_level"] || ""; const explanation = tObj["explanation"] || tObj["why_compliance_level_partial"] || ""; out += `${emoji} Ticket compliance check

\n`; - if (url && url.trim()) { + if (url && url.trim() && !/^(n\/?a|none|no ticket|no\b)/i.test(url.trim()) && /^https?:\/\//i.test(url.trim())) { const id = url.trim().split("/").filter(Boolean).pop() || url.trim(); out += `**[${id}](<${url.trim()}>) — ${compliance || "Partially"}**\n\n`; } else { diff --git a/server/src/secrets.ts b/server/src/secrets.ts index 66bee31..f7d431f 100644 --- a/server/src/secrets.ts +++ b/server/src/secrets.ts @@ -17,8 +17,30 @@ export function loadSecrets(opts?: { webhookSecret?: string; }): Secrets { const appDir = opts?.appDir ?? "/var/lib/pr-agent-server"; - const privateKey = readFileSync(join(appDir, "private-key.pem"), "utf8"); - const omniKey = readFileSync(join(appDir, "omniroute_key"), "utf8").trim(); + const candidates = [ + join(appDir, "private-key.pem"), + join(process.env.HOME ?? "/home/code", ".hermes", "keys", "pr-agent-key.pem"), + ]; + let privateKey = ""; + for (const p of candidates) { + try { + privateKey = readFileSync(p, "utf8"); + break; + } catch { + // try next + } + } + if (!privateKey) throw new Error(`private-key.pem not found in ${candidates.join(", ")}`); + let omniKey = ""; + for (const p of [join(appDir, "omniroute_key"), join(process.env.HOME ?? "/home/code", ".hermes", "omniroute_key")]) { + try { + omniKey = readFileSync(p, "utf8").trim(); + if (omniKey) break; + } catch { + // try next + } + } + if (!omniKey) throw new Error(`omniroute_key not found in ${appDir}`); return { appId: opts?.appId ?? 4319749, privateKey, diff --git a/src/auto_merge_bot.py b/src/auto_merge_bot.py index 0c93f89..870beef 100644 --- a/src/auto_merge_bot.py +++ b/src/auto_merge_bot.py @@ -76,7 +76,7 @@ def get_pr_reviews(token: str, repo_full: str, pr_number: int) -> list: 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:4002/api/v1/notify_review") + 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={