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:
@@ -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
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -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} <strong>Ticket compliance check</strong><br><br>\n`;
|
out += `<tr><td>${emoji} <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
@@ -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,
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
Reference in New Issue
Block a user