feat(server): production cut-over to Bun — notify_review, setup/callback, key resolution

- 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.
This commit is contained in:
asepharyana
2026-09-21 13:47:47 +07:00
parent 055501aed1
commit bc69998d8e
6 changed files with 120 additions and 16 deletions
+12 -1
View File
@@ -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";
+81 -10
View File
@@ -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<void> {
async function sendDiscord(webhook: string, content: string, title?: string): Promise<void> {
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<WebhookEnv>) {
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<WebhookEnv>) {
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<WebhookEnv>) {
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();
}
+1 -1
View File
@@ -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 += `<tr><td>${emoji}&nbsp;<strong>Ticket compliance check</strong><br><br>\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 {
+24 -2
View File
@@ -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,