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
+1 -1
View File
@@ -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=""): def post_discord_notification(repo_full, pr_num, status, summary="", score="", url=""):
"""Fire-and-forget Discord notification via pr-agent server internal endpoint.""" """Fire-and-forget Discord notification via pr-agent server internal endpoint."""
import httpx 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: try:
with httpx.Client(timeout=5) as client: with httpx.Client(timeout=5) as client:
client.post(notify_url, json={ client.post(notify_url, json={
+12 -1
View File
@@ -3,6 +3,8 @@
// review pipeline, with env-var overrides (same names as the Python server used, // review pipeline, with env-var overrides (same names as the Python server used,
// minus the Dynaconf prefix dance — we read plain env vars). // minus the Dynaconf prefix dance — we read plain env vars).
import { readFileSync } from "node:fs";
export interface Config { export interface Config {
// model routing // model routing
model: string; model: string;
@@ -84,11 +86,20 @@ export function loadConfig(): Config {
// Key resolution: the Python server used ANTHROPIC_API_KEY = omni key for // Key resolution: the Python server used ANTHROPIC_API_KEY = omni key for
// 9router. Prefer OMNIROUTE_API_KEY (verified live), fall back to // 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 = const apiKey =
env.OMNIROUTE_API_KEY || env.OMNIROUTE_API_KEY ||
env.ANTHROPIC_API_KEY || env.ANTHROPIC_API_KEY ||
env.OPENAI_API_KEY || env.OPENAI_API_KEY ||
fileKey ||
""; "";
const baseUrl = const baseUrl =
env.OPENAI_API_BASE || "https://9router.asepharyana.my.id/v1"; 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) // Fire and forget: dispatch review; respond fast (GitHub expects < 10s)
void runReview(env.cfg, owner, repo, pr.number, env.privateKeyPem) void runReview(env.cfg, owner, repo, pr.number, env.privateKeyPem)
.then(async (result) => { .then(async (result) => {
if (env.analyticsDir) { console.log(`[webhook] review done for ${owner}/${repo}#${pr.number}: ${result.status}, model ${result.model}, md ${result.markdown.length} chars`);
try { if (env.analyticsDir) {
const fsMod = await import("node:fs"); try {
fsMod.appendFileSync( const fsMod = await import("node:fs");
fsMod.appendFileSync(
`${env.analyticsDir}/pr-agent.bun.jsonl`, `${env.analyticsDir}/pr-agent.bun.jsonl`,
JSON.stringify({ JSON.stringify({
time: new Date().toISOString(), time: new Date().toISOString(),
@@ -111,6 +112,7 @@ export async function handleWebhook(
} }
}) })
.catch((e) => { .catch((e) => {
console.error(`[webhook] review FAILED for ${owner}/${repo}#${pr.number}:`, e instanceof Error ? e.stack ?? e.message : e);
if (env.discordAlertWebhookUrl) { if (env.discordAlertWebhookUrl) {
void sendDiscord( void sendDiscord(
env.discordAlertWebhookUrl, env.discordAlertWebhookUrl,
@@ -134,7 +136,7 @@ async function getHttpx() {
return fetch; 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 { try {
await fetch(webhook, { await fetch(webhook, {
method: "POST", method: "POST",
@@ -153,9 +155,12 @@ async function sendDiscord(webhook: string, content: string, title: string): Pro
export function startServer(env?: Partial<WebhookEnv>) { export function startServer(env?: Partial<WebhookEnv>) {
const cfg = env?.cfg ?? loadConfig(); const cfg = env?.cfg ?? loadConfig();
const privateKeyPem = const appDir = process.env.PR_AGENT_APP_DIR || "/var/lib/pr-agent-server";
env?.privateKeyPem ?? const privateKeyPem =
readPrivateKey(process.env.PRIVATE_KEY_PATH || "/opt/pr-agent-server/private-key.pem"); env?.privateKeyPem ??
(readPrivateKey(process.env.PRIVATE_KEY_PATH || `${appDir}/private-key.pem`) ||
readPrivateKey(`${appDir}/private-key.pem`) ||
"");
const webhookSecret = const webhookSecret =
env?.webhookSecret ?? process.env.GITHUB_WEBHOOK_SECRET ?? ""; env?.webhookSecret ?? process.env.GITHUB_WEBHOOK_SECRET ?? "";
const analyticsDir = const analyticsDir =
@@ -181,6 +186,40 @@ export function startServer(env?: Partial<WebhookEnv>) {
if (url.pathname === "/health") { if (url.pathname === "/health") {
return Response.json({ status: "ok", model: cfg.model }); 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 (url.pathname === "/api/v1/github_webhooks" || url.pathname === "/") {
if (req.method !== "POST") { if (req.method !== "POST") {
return Response.json({ ok: true }); return Response.json({ ok: true });
@@ -191,6 +230,33 @@ export function startServer(env?: Partial<WebhookEnv>) {
const result = await handleWebhook(fullEnv, body, sig, event); const result = await handleWebhook(fullEnv, body, sig, event);
return Response.json(result.body, { status: result.status }); 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") { if (url.pathname === "/api/metrics") {
return new Response(generateMetrics(), { return new Response(generateMetrics(), {
headers: { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" }, 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", "# HELP pr_agent_requests_by_command PR-Agent events by command",
"# TYPE pr_agent_requests_by_command counter", "# TYPE pr_agent_requests_by_command counter",
].join("\n") + "\n"; ].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 compliance = tObj["overall_compliance_level"] || tObj["ticket_compliance_level"] || "";
const explanation = tObj["explanation"] || tObj["why_compliance_level_partial"] || ""; const explanation = tObj["explanation"] || tObj["why_compliance_level_partial"] || "";
out += `<tr><td>${emoji}&nbsp;<strong>Ticket compliance check</strong><br><br>\n`; 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(); const id = url.trim().split("/").filter(Boolean).pop() || url.trim();
out += `**[${id}](<${url.trim()}>) — ${compliance || "Partially"}**\n\n`; out += `**[${id}](<${url.trim()}>) — ${compliance || "Partially"}**\n\n`;
} else { } else {
+24 -2
View File
@@ -17,8 +17,30 @@ export function loadSecrets(opts?: {
webhookSecret?: string; webhookSecret?: string;
}): Secrets { }): Secrets {
const appDir = opts?.appDir ?? "/var/lib/pr-agent-server"; const appDir = opts?.appDir ?? "/var/lib/pr-agent-server";
const privateKey = readFileSync(join(appDir, "private-key.pem"), "utf8"); const candidates = [
const omniKey = readFileSync(join(appDir, "omniroute_key"), "utf8").trim(); 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 { return {
appId: opts?.appId ?? 4319749, appId: opts?.appId ?? 4319749,
privateKey, privateKey,
+1 -1
View File
@@ -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 = ""): 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.""" """Fire-and-forget Discord notification via the server's internal endpoint."""
import httpx 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: try:
with httpx.Client(timeout=5) as client: with httpx.Client(timeout=5) as client:
client.post(notify_url, json={ client.post(notify_url, json={