feat(server): Bun/TypeScript re-implementation of PR review engine

Reimplements the pr_agent review pipeline (previously Python package) in
Bun/TypeScript, verified end-to-end against a real GitHub App + 9router:

- src/diff.ts: git patch processing (extend_patch, line-numbered hunks,
  token-budget generate_full_patch, generated/invalid file filters)
- src/review.ts: orchestrator (fetch PR/diff -> render prompts -> LLM ->
  YAML parse -> markdown render -> publish), fallback models
- src/github.ts: GitHub App auth via @octokit/auth-app (JWT -> installation
  token, custom authStrategy for Bun), PR/diff/languages/comments REST
- src/prompts.ts: verbatim pr_reviewer_prompts.toml port (nunjucks templates)
- src/yaml.ts: resilient load_yaml with 9 fallback fixers
- src/markdown.ts: convert_to_markdown_v2 port ('PR Reviewer Guide' comment)
- src/llm.ts: 9router OpenAI-compatible chat completions (non-stream,
  temp omitted for claude-opus-5), timeout 600s
- src/token.ts: js-tiktoken o200k counting
- src/index.ts: webhook server (HMAC verify, segment analytics, /health,
  /api/metrics) + cli.ts one-shot review
- e2e.ts: real-world harness (bun e2e --repo o/r --pr N [--publish])

Verified: bun test 13/13, tsc clean, E2E published a real review comment
(## PR Reviewer Guide) on asepharyana/nextjs-template#19 via GitHub App
MythEclipseBotReview + claude-opus-5 through 9router.

Python run_server.py + pr_agent remain for the live queue worker; server/
is the replacement path.
This commit is contained in:
asepharyana
2026-09-21 12:50:10 +07:00
parent 835552c5ca
commit 055501aed1
19 changed files with 3119 additions and 1 deletions
+8 -1
View File
@@ -6,7 +6,7 @@ whsec*.txt
.secrets.toml
.env
.env.*
**/node_modules/**
# Python
__pycache__/
*.pyc
@@ -22,3 +22,10 @@ build/
# Runtime data
*.log
*.pid
# Planning files (session scratch)
.planning/
.tmp/
# Bun TS build artifacts
*.tsbuildinfo
+107
View File
@@ -0,0 +1,107 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "pr-agent-server",
"dependencies": {
"@octokit/auth-app": "^8.3.1",
"@octokit/rest": "^22.0.1",
"js-tiktoken": "^1.0.21",
"nunjucks": "^3.2.4",
"parse-diff": "^0.12.0",
"yaml": "^2.9.1",
},
"devDependencies": {
"@types/bun": "latest",
"@types/nunjucks": "^3.2.6",
"typescript": "^5.9.0",
},
},
},
"packages": {
"@octokit/auth-app": ["@octokit/auth-app@8.3.1", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.5", "@octokit/auth-oauth-user": "^6.0.4", "@octokit/request": "^10.0.16", "@octokit/request-error": "^7.1.2", "@octokit/types": "^18.0.0", "toad-cache": "^3.7.0", "universal-github-app-jwt": "^2.2.0", "universal-user-agent": "^7.0.0" } }, "sha512-XHWxZsF4mvGTQCv7AOxgby9t9OjVO19FBuKq+QmyBxFrf+B2hR/P5j/MTF8g95bITf8cRtR17KIXThpFNqiKbg=="],
"@octokit/auth-oauth-app": ["@octokit/auth-oauth-app@9.0.5", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.5", "@octokit/auth-oauth-user": "^6.0.4", "@octokit/request": "^10.0.16", "@octokit/types": "^18.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-Hn2yAlIDjFLGjAz7t+CRu7e9VYoFW1W3LLyOLyahFoJGlCf4PtDkHKtRET8w/2CJpsrC0FxIF5g5Xtx9E4O78w=="],
"@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@8.0.5", "", { "dependencies": { "@octokit/oauth-methods": "^6.0.5", "@octokit/request": "^10.0.16", "@octokit/types": "^18.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-kdJtjRhykMLJrMdcoC56XUBDfoUAuWEFKpkDxwYim+bNwYw1MreLolk+PLnwEYT8djm589YepeMuJ8+ECAIzVQ=="],
"@octokit/auth-oauth-user": ["@octokit/auth-oauth-user@6.0.4", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.5", "@octokit/oauth-methods": "^6.0.5", "@octokit/request": "^10.0.16", "@octokit/types": "^18.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-eya2pMJ9pCBq1jrm1Rf6J3NI+NemEpzDMLo9J/GpRoK0KgdxIVXWX4LESIfwJ+GYeoq6QFIlUJNi1lVp5FBgmw=="],
"@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
"@octokit/core": ["@octokit/core@7.0.8", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.5", "@octokit/request": "^10.0.16", "@octokit/request-error": "^7.1.2", "@octokit/types": "^18.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-L7y8eYc+AwxGr2PWI4WFt1VG4TiJ66c26BD16mXpYIlXxG0SMigM1+m4aTSlYyBr5BlQsGAlz8uDCoZN4SEMcg=="],
"@octokit/endpoint": ["@octokit/endpoint@11.0.5", "", { "dependencies": { "@octokit/types": "^18.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ=="],
"@octokit/graphql": ["@octokit/graphql@9.0.5", "", { "dependencies": { "@octokit/request": "^10.0.16", "@octokit/types": "^18.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-bt/hm03LeU6Vy7FwTrkkC9p3XGT/lBwClglMqxBSe5/q0E5CdJTXeAqEI0vlw89/LF/G6tryTIH8HirZ3prMVg=="],
"@octokit/oauth-authorization-url": ["@octokit/oauth-authorization-url@8.0.0", "", {}, "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ=="],
"@octokit/oauth-methods": ["@octokit/oauth-methods@6.0.5", "", { "dependencies": { "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/request": "^10.0.16", "@octokit/request-error": "^7.1.2", "@octokit/types": "^18.0.0" } }, "sha512-/mAz7taDZD7DcV4zcG6TDw3Kr6TOldmYBRpvA62C3WIxRMmcgX8XxsTvVQomqKE7VEk/BgYhUao1aR2tYGyIKg=="],
"@octokit/openapi-types": ["@octokit/openapi-types@29.0.1", "", {}, "sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg=="],
"@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="],
"@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="],
"@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="],
"@octokit/request": ["@octokit/request@10.0.16", "", { "dependencies": { "@octokit/endpoint": "^11.0.5", "@octokit/request-error": "^7.1.2", "@octokit/types": "^18.0.0", "content-type": "^3.0.0", "json-with-bigint": "^3.5.12", "universal-user-agent": "^7.0.2" } }, "sha512-A0zWGjHzISIb+9ccG8s0dq7LKO5zVpJLRICjgUb+sJxEWqn8RUHB1rD3AE51+PECvXHIxqZ1VVvs4fHTSD9nUQ=="],
"@octokit/request-error": ["@octokit/request-error@7.1.2", "", { "dependencies": { "@octokit/types": "^18.0.0" } }, "sha512-XZRuT3xZ84D3gYErI1DZvhJ33dCWVV6uzBtWkaBB4TvA/L6eOeTZodxLFVB44bBEEo3vEx7y00UfX1tBLrtLRg=="],
"@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="],
"@octokit/types": ["@octokit/types@18.0.0", "", { "dependencies": { "@octokit/openapi-types": "^29.0.1" } }, "sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA=="],
"@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
"@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="],
"@types/nunjucks": ["@types/nunjucks@3.2.6", "", {}, "sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w=="],
"a-sync-waterfall": ["a-sync-waterfall@1.0.1", "", {}, "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA=="],
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
"content-type": ["content-type@3.1.1", "", {}, "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA=="],
"js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="],
"json-with-bigint": ["json-with-bigint@3.5.12", "", {}, "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w=="],
"nunjucks": ["nunjucks@3.2.4", "", { "dependencies": { "a-sync-waterfall": "^1.0.0", "asap": "^2.0.3", "commander": "^5.1.0" }, "peerDependencies": { "chokidar": "^3.3.0" }, "optionalPeers": ["chokidar"], "bin": { "nunjucks-precompile": "bin/precompile" } }, "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ=="],
"parse-diff": ["parse-diff@0.12.0", "", {}, "sha512-2Xr5mW4Bqd4CqYq2zttfw/RZraK+KcRuJvNkJzbDk3ea67Ap525XeTvBdtDE5tigJMVzIx/DMUzsShAf6+5SCA=="],
"toad-cache": ["toad-cache@3.7.4", "", {}, "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"universal-github-app-jwt": ["universal-github-app-jwt@2.2.2", "", {}, "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw=="],
"universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
"yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="],
"@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
}
}
+79
View File
@@ -0,0 +1,79 @@
// E2E test harness — runs the full review pipeline against the REAL GitHub
// App + 9router. Not part of `bun test` (needs secrets); run explicitly.
//
// bun e2e --repo <owner/repo> --pr <number>
//
// Validates: GitHub App auth (JWT+installation token), diff fetch, token
// budget, prompt render, LLM call via 9router, YAML parse, markdown render.
// Does NOT publish a comment unless --publish is passed.
import { loadSecrets } from "./src/secrets";
import { loadConfig } from "./src/config";
import { GitHubProvider } from "./src/github";
import { runReview } from "./src/review";
import { countTokens } from "./src/token";
const args = process.argv.slice(2);
const repoArg = args[args.indexOf("--repo") + 1];
const prArg = Number(args[args.indexOf("--pr") + 1]);
const publish = args.includes("--publish");
if (!repoArg || !prArg) {
console.error("usage: bun e2e --repo owner/repo --pr N [--publish]");
process.exit(2);
}
const secrets = loadSecrets({
appDir: process.env.PR_AGENT_APP_DIR ?? "/var/lib/pr-agent-server",
});
const cfg = loadConfig();
cfg.llm.baseUrl = secrets.baseUrl;
cfg.llm.apiKey = secrets.omniKey;
cfg.github.appId = secrets.appId;
cfg.github.privateKey = secrets.privateKey;
const gh = new GitHubProvider(cfg, repoArg.split("/")[0], repoArg.split("/")[1], prArg, secrets.privateKey);
const startedAt = Date.now();
console.log(`\n=== E2E review ${repoArg}#${prArg} (publish=${publish}) ===`);
// sanity: hitting the GitHub API to prove App auth works before the long LLM call
try {
const pr = await gh.getPr();
console.log(`PR ${pr.number}: "${pr.title}" (${pr.additions}+ / ${pr.deletions}-, ${pr.changedFiles} files)`);
} catch (err) {
console.error("GitHub App auth / PR fetch failed:", err instanceof Error ? err.message : err);
process.exit(1);
}
try {
const res = await runReview(
cfg,
repoArg.split("/")[0],
repoArg.split("/")[1],
prArg,
secrets.privateKey,
{ publish },
);
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
console.log(`\n=== DONE in ${elapsed}s ===`);
console.log("status:", res.status);
if (res.markdown) {
console.log(`comment length: ${res.markdown.length} chars`);
console.log(`comment tokens: ${countTokens(res.markdown)}`);
console.log("--- comment head ---");
console.log(res.markdown.slice(0, 600));
console.log("--- comment tail ---");
console.log(res.markdown.slice(-600));
} else {
console.log("WARNING: markdown is empty/missing");
console.log("markdown present:", !!res.markdown, "| data present:", !!res.data);
}
console.log("model:", res.model, "| promptTokens:", res.promptTokens, "| completionTokens:", res.completionTokens);
process.exit(res.status === "success" ? 0 : 1);
} catch (err) {
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
console.error(`\n=== FAILED after ${elapsed}s ===`);
console.error(err instanceof Error ? err.stack ?? err.message : err);
process.exit(1);
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "pr-agent-server",
"version": "2.0.0",
"description": "PR-Agent re-implementation in Bun/TypeScript — GitHub App webhook server + AI PR review engine",
"type": "module",
"module": "src/index.ts",
"main": "src/index.ts",
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts",
"review": "bun src/cli.ts",
"test": "bun test",
"typecheck": "bunx tsc --noEmit"
},
"dependencies": {
"@octokit/auth-app": "^8.3.1",
"@octokit/rest": "^22.0.1",
"js-tiktoken": "^1.0.21",
"nunjucks": "^3.2.4",
"parse-diff": "^0.12.0",
"yaml": "^2.9.1"
},
"devDependencies": {
"@types/bun": "latest",
"@types/nunjucks": "^3.2.6",
"typescript": "^5.9.0"
}
}
+59
View File
@@ -0,0 +1,59 @@
// CLI — one-shot review: `bun start --repo owner/name --pr N`
// Also used by the queue worker to trigger reviews without a webhook.
import { loadConfig } from "./config";
import { runReview } from "./review";
async function main() {
const args = process.argv.slice(2);
const getArg = (name: string): string | undefined => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const has = (name: string): boolean => args.includes(name);
if (has("--help")) {
console.log(
"Usage: bun src/cli.ts --repo owner/name --pr N [--private-key PATH] [--no-publish] [--review-all]",
);
return;
}
const repoArg = getArg("--repo");
const prArg = getArg("--pr");
if (!repoArg || !prArg) {
console.error("Missing --repo owner/name or --pr N");
process.exit(1);
}
const [owner, repo] = repoArg.split("/");
const prNumber = Number(prArg);
const cfg = loadConfig();
const keyPath = getArg("--private-key") || process.env.PRIVATE_KEY_PATH || "/opt/pr-agent-server/private-key.pem";
const fs = await import("node:fs");
const privateKey = fs.readFileSync(keyPath, "utf-8");
const result = await runReview(cfg, owner, repo, prNumber, privateKey, {
publish: !has("--no-publish"),
});
console.log(JSON.stringify(
{
model: result.model,
promptTokens: result.promptTokens,
completionTokens: result.completionTokens,
cachedTokens: result.cachedTokens,
remainingFiles: result.remainingFiles.length,
markdownLen: result.markdown.length,
},
null,
2,
));
console.log("\n--- MARKDOWN ---\n");
console.log(result.markdown);
}
main().catch((e) => {
console.error("CLI failed:", (e as Error).message);
process.exit(1);
});
+179
View File
@@ -0,0 +1,179 @@
// Config for the Bun PR-Agent re-implementation.
// Mirrors pr_agent settings/configuration.toml defaults that matter to the
// review pipeline, with env-var overrides (same names as the Python server used,
// minus the Dynaconf prefix dance — we read plain env vars).
export interface Config {
// model routing
model: string;
fallbackModels: string[];
maxModelTokens: number; // capping for get_max_tokens (config.max_model_tokens)
customModelMaxTokens: number; // used when a model is not in the MAX_TOKENS table
aiTimeoutMs: number;
temperature: number;
// review features
prReviewer: {
requireScoreReview: boolean;
requireTestsReview: boolean;
requireEstimateEffortToReview: boolean;
requireSecurityReview: boolean;
requireCanBeSplitReview: boolean;
requireEstimateContributionTimeCost: boolean;
requireTodoScan: boolean;
numMaxFindings: number;
persistentComment: boolean;
inlineKeyIssues: boolean;
publishOutputNoSuggestions: boolean;
enableHelpText: boolean;
enableReviewCoverageFooter: boolean;
enableIntroText: boolean;
extraInstructions: string;
finalUpdateMessage: boolean;
};
// pr_description — needed when reusing the description pipeline later
prDescription: {
maxAiCalls: number;
enableLargePrHandling: boolean;
};
// git/diff
patchExtraLinesBefore: number;
patchExtraLinesAfter: number;
patchExtensionSkipTypes: string[];
allowDynamicContext: boolean;
maxExtraLinesBeforeDynamicContext: number;
maxDescriptionTokens: number;
maxCommitsTokens: number;
outputBufferSoftThreshold: number;
outputBufferHardThreshold: number;
// github
github: {
baseUrl: string; // api.github.com
deploymentType: string; // "app"
appId: string;
publishAsCheckRun: boolean;
rateLimitRetries: number;
rateLimitDelaySec: number;
};
// llm
llm: {
baseUrl: string;
apiKey: string;
extraHeaders: Record<string, string>;
};
}
const env = process.env;
function envInt(name: string, def: number): number {
const v = env[name];
if (v === undefined || v === "") return def;
const n = Number(v);
return Number.isFinite(n) ? n : def;
}
export function loadConfig(): Config {
const model = env.PR_AGENT_MODEL || "claude-opus-5";
let fallbackModels: string[] = ["claude-sonnet-5", "claude-haiku-4-5-20251001"];
if (env.PR_AGENT_FALLBACK_MODELS) {
try {
fallbackModels = JSON.parse(env.PR_AGENT_FALLBACK_MODELS);
} catch {
fallbackModels = env.PR_AGENT_FALLBACK_MODELS.split(",").map((s) => s.trim());
}
}
// 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.
const apiKey =
env.OMNIROUTE_API_KEY ||
env.ANTHROPIC_API_KEY ||
env.OPENAI_API_KEY ||
"";
const baseUrl =
env.OPENAI_API_BASE || "https://9router.asepharyana.my.id/v1";
return {
model,
fallbackModels,
maxModelTokens: envInt("PR_AGENT_MAX_MODEL_TOKENS", 128000),
customModelMaxTokens: envInt("PR_AGENT_CUSTOM_MODEL_MAX_TOKENS", 128000),
aiTimeoutMs: envInt("PR_AGENT_AI_TIMEOUT", 600) * 1000,
temperature: envInt("PR_AGENT_TEMPERATURE", 20) / 100,
prReviewer: {
requireScoreReview: (env.PR_AGENT_REQUIRE_SCORE || "true").toLowerCase() === "true",
requireTestsReview: (env.PR_AGENT_REQUIRE_TESTS || "true").toLowerCase() === "true",
requireEstimateEffortToReview:
(env.PR_AGENT_REQUIRE_EFFORT || "true").toLowerCase() === "true",
requireSecurityReview:
(env.PR_AGENT_REQUIRE_SECURITY || "true").toLowerCase() === "true",
requireCanBeSplitReview:
(env.PR_AGENT_REQUIRE_SPLIT || "false").toLowerCase() === "true",
requireEstimateContributionTimeCost:
(env.PR_AGENT_REQUIRE_TIME_COST || "false").toLowerCase() === "true",
requireTodoScan: (env.PR_AGENT_REQUIRE_TODO || "false").toLowerCase() === "true",
numMaxFindings: envInt("PR_AGENT_MAX_FINDINGS", 3),
persistentComment:
(env.PR_AGENT_PERSISTENT_COMMENT ?? "true").toLowerCase() === "true",
inlineKeyIssues:
(env.PR_AGENT_INLINE_KEY_ISSUES || "false").toLowerCase() === "true",
publishOutputNoSuggestions:
(env.PR_AGENT_PUBLISH_NO_SUGGESTIONS ?? "true").toLowerCase() === "true",
enableHelpText: (env.PR_AGENT_ENABLE_HELP_TEXT || "false").toLowerCase() === "true",
enableReviewCoverageFooter:
(env.PR_AGENT_ENABLE_COVERAGE_FOOTER ?? "true").toLowerCase() === "true",
enableIntroText: (env.PR_AGENT_ENABLE_INTRO ?? "true").toLowerCase() === "true",
extraInstructions: env.PR_AGENT_EXTRA_INSTRUCTIONS || "",
finalUpdateMessage: (env.PR_AGENT_FINAL_UPDATE ?? "true").toLowerCase() === "true",
},
prDescription: {
maxAiCalls: envInt("PR_AGENT_MAX_AI_CALLS", 4),
enableLargePrHandling:
(env.PR_AGENT_LARGE_PR_HANDLING ?? "true").toLowerCase() === "true",
},
patchExtraLinesBefore: envInt("PR_AGENT_PATCH_EXTRA_BEFORE", 5),
patchExtraLinesAfter: envInt("PR_AGENT_PATCH_EXTRA_AFTER", 1),
patchExtensionSkipTypes: [".md", ".txt"],
allowDynamicContext: (env.PR_AGENT_DYNAMIC_CONTEXT ?? "true").toLowerCase() === "true",
maxExtraLinesBeforeDynamicContext: envInt("PR_AGENT_MAX_DYNAMIC_CONTEXT", 10),
maxDescriptionTokens: envInt("PR_AGENT_MAX_DESCRIPTION_TOKENS", 500),
maxCommitsTokens: envInt("PR_AGENT_MAX_COMMITS_TOKENS", 500),
outputBufferSoftThreshold: envInt("PR_AGENT_OUTPUT_SOFT", 1500),
outputBufferHardThreshold: envInt("PR_AGENT_OUTPUT_HARD", 1000),
github: {
baseUrl: env.GITHUB_API_BASE || "https://api.github.com",
deploymentType: env.GITHUB__DEPLOYMENT_TYPE || "app",
appId: env.GITHUB_APP_ID || "4319749",
publishAsCheckRun:
(env.PR_AGENT_PUBLISH_CHECK_RUN || "false").toLowerCase() === "true",
rateLimitRetries: envInt("PR_AGENT_RATE_LIMIT_RETRIES", 5),
rateLimitDelaySec: envInt("PR_AGENT_RATE_LIMIT_DELAY", 2),
},
llm: {
baseUrl,
apiKey,
extraHeaders: {},
},
};
}
export function getModelTokenLimit(model: string, cfg?: Config): number {
const c = cfg ?? loadConfig();
const table: Record<string, number> = {
"claude-opus-5": 1000000,
"anthropic/claude-opus-5": 1000000,
"claude-sonnet-5": 1000000,
"anthropic/claude-sonnet-5": 1000000,
"claude-haiku-4-5-20251001": 200000,
"anthropic/claude-haiku-4-5-20251001": 200000,
"gpt-5": 200000,
"gpt-5.6": 1050000,
// fallback for unknown models
};
let limit = table[model];
if (limit === undefined) {
limit = c.customModelMaxTokens > 0 ? c.customModelMaxTokens : 128000;
}
if (c.maxModelTokens > 0) limit = Math.min(c.maxModelTokens, limit);
return limit;
}
+618
View File
@@ -0,0 +1,618 @@
// Diff processing — port of pr_agent.algo.git_patch_processing +
// pr_processing logic that matters for the review prompt.
// Includes: hunk header regex, extend_patch (context extension + dynamic
// context), omit deletion-only hunks, decouple_and_convert_to_hunks_with_lines_numbers,
// full diff generation with per-file token budget, and the added/modified/deleted
// file summaries.
import type { Config } from "./config";
import { countTokens } from "./token";
export enum EditType {
ADDED = "added",
DELETED = "deleted",
MODIFIED = "modified",
RENAMED = "renamed",
UNKNOWN = "unknown",
}
export interface FilePatchInfo {
filename: string;
baseFile: string;
headFile: string;
patch: string;
editType: EditType;
numPlusLines: number;
numMinusLines: number;
}
// Same regex as Python: ^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)
const RE_HUNK_HEADER =
/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)/;
const MAX_EXTRA_LINES = 10;
const AUTO_GENERATED_EXACT = new Set([
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"composer.lock",
"Gemfile.lock",
"poetry.lock",
"go.sum",
".terraform.lock.hcl",
"uv.lock",
"Cargo.lock",
"Pipfile.lock",
"mix.lock",
"pubspec.lock",
"bun.lockb",
]);
const AUTO_GENERATED_SUFFIXES = [".min.js", ".min.css", ".js.map", ".ts.map", ".css.map"];
// Bad extensions: subset of pr_agent's large map. Kept small but functional;
// configurable via env if needed.
const BAD_EXTENSIONS = new Set([
"jpg", "jpeg", "png", "gif", "bmp", "webp", "ico", "svg", "tiff",
"woff", "woff2", "ttf", "otf", "eot",
"zip", "gz", "tar", "7z", "rar", "pdf", "wasm", "mp3", "mp4", "mov",
"avi", "mkv", "exe", "dll", "so", "bin",
]);
export function isGeneratedOrInvalidFile(filename: string): boolean {
if (!filename) return false;
const base = filename.replace(/\\/g, "/").split("/").pop() ?? "";
if (AUTO_GENERATED_EXACT.has(base)) return true;
if (AUTO_GENERATED_SUFFIXES.some((s) => filename.endsWith(s))) return true;
const ext = filename.split(".").pop() ?? "";
return BAD_EXTENSIONS.has(ext);
}
export function shouldSkipPatch(filename: string, cfg?: Config): boolean {
const skip = cfg?.patchExtensionSkipTypes ?? [".md", ".txt"];
return skip.some((t) => filename.endsWith(t));
}
// ── hunk helpers ──────────────────────────────────────────────────────────
interface HunkHeader {
start1: number;
size1: number;
start2: number;
size2: number;
sectionHeader: string;
}
export function parseHunkHeader(line: string): HunkHeader | null {
const m = RE_HUNK_HEADER.exec(line);
if (!m) return null;
return {
start1: m[1] ? parseInt(m[1], 10) : 0,
size1: m[2] ? parseInt(m[2], 10) : 1,
start2: m[3] ? parseInt(m[3], 10) : 0,
size2: m[4] ? parseInt(m[4], 10) : 1,
sectionHeader: m[5] || "",
};
}
function decodeIfBytes(s: string): string {
return s;
}
export function omitDeletionHunks(patch: string): string {
const lines = patch.split("\n");
const addedPatched: string[] = [];
let tempHunk: string[] = [];
let addHunk = false;
let insideHunk = false;
for (const line of lines) {
if (line.startsWith("@@")) {
if (parseHunkHeader(line)) {
if (insideHunk) {
if (addHunk) addedPatched.push(...tempHunk);
tempHunk = [];
addHunk = false;
}
tempHunk.push(line);
insideHunk = true;
}
} else {
tempHunk.push(line);
if (line) {
if (line[0] === "+") addHunk = true;
}
}
}
if (insideHunk && addHunk) addedPatched.push(...tempHunk);
return addedPatched.join("\n");
}
export function handlePatchDeletions(
patch: string,
originalFile: string,
newFile: string,
filename: string,
editType: EditType,
): string | null {
if (!newFile && (editType === EditType.DELETED || editType === EditType.UNKNOWN)) {
return null; // deleted file → no patch
}
const patchNew = omitDeletionHunks(patch);
return patchNew;
}
// ── patch extension (context lines) ───────────────────────────────────────
export function extendPatch(
patchStr: string,
originalFileStr: string,
patchExtraLinesBefore: number,
patchExtraLinesAfter: number,
filename: string,
newFileStr = "",
cfg?: Config,
): string {
if (
!patchStr ||
(patchExtraLinesBefore === 0 && patchExtraLinesAfter === 0) ||
!originalFileStr
) {
return patchStr;
}
if (shouldSkipPatch(filename, cfg)) return patchStr;
const allowDynamicContext = cfg?.allowDynamicContext ?? true;
const maxDynamicBefore = cfg?.maxExtraLinesBeforeDynamicContext ?? 10;
const patchExtraBeforeDynamic =
maxDynamicBefore > MAX_EXTRA_LINES ? MAX_EXTRA_LINES : maxDynamicBefore;
const fileOriginalLines = originalFileStr.split("\n");
const fileNewLines = newFileStr ? newFileStr.split("\n") : [];
const lenOriginalLines = fileOriginalLines.length;
const patchLines = patchStr.split("\n");
const extendedPatchLines: string[] = [];
let isValidHunk = true;
let start1 = -1, size1 = -1, start2 = -1, size2 = -1;
for (let i = 0; i < patchLines.length; i++) {
const line = patchLines[i];
if (line.startsWith("@@")) {
const match = parseHunkHeader(line);
if (match) {
// finish previous hunk
if (isValidHunk && start1 !== -1 && patchExtraLinesAfter > 0) {
const slice = fileOriginalLines.slice(
start1 + size1 - 1,
start1 + size1 - 1 + patchExtraLinesAfter,
);
extendedPatchLines.push(...slice.map((l) => ` ${l}`));
}
let sectionHeader = match.sectionHeader;
start1 = match.start1;
size1 = match.size1;
start2 = match.start2;
size2 = match.size2;
isValidHunk = checkHunkLinesMatch(i, fileOriginalLines, patchLines, start1);
if (isValidHunk && (patchExtraLinesBefore > 0 || patchExtraLinesAfter > 0)) {
const calcContextLimits = (before: number) => {
let extStart1 = Math.max(1, start1 - before);
let extSize1 = size1 + (start1 - extStart1) + patchExtraLinesAfter;
let extStart2 = Math.max(1, start2 - before);
let extSize2 = size2 + (start2 - extStart2) + patchExtraLinesAfter;
if (extStart1 - 1 + extSize1 > lenOriginalLines) {
const deltaCap = extStart1 - 1 + extSize1 - lenOriginalLines;
extSize1 = Math.max(extSize1 - deltaCap, size1);
extSize2 = Math.max(extSize2 - deltaCap, size2);
}
return { extStart1, extSize1, extStart2, extSize2 };
};
let limits = calcContextLimits(patchExtraLinesBefore);
if (allowDynamicContext && fileNewLines.length) {
limits = calcContextLimits(patchExtraBeforeDynamic);
const linesBeforeOriginal = fileOriginalLines.slice(
limits.extStart1 - 1,
start1 - 1,
);
const linesBeforeNew = fileNewLines.slice(
limits.extStart2 - 1,
start2 - 1,
);
let foundHeader = false;
if (sectionHeader) {
for (let j = 0; j < linesBeforeOriginal.length; j++) {
if (linesBeforeOriginal[j].includes(sectionHeader)) {
limits.extStart1 += j;
limits.extStart2 += j;
limits.extSize1 -= j;
limits.extSize2 -= j;
const dynOrig = linesBeforeOriginal.slice(j);
const dynNew = linesBeforeNew.slice(j);
if (arraysEqual(dynOrig, dynNew)) {
foundHeader = true;
sectionHeader = "";
}
break;
}
}
}
if (!foundHeader) limits = calcContextLimits(patchExtraLinesBefore);
}
let deltaLinesOriginal = fileOriginalLines.slice(
limits.extStart1 - 1,
start1 - 1,
).map((l) => ` ${l}`);
if (fileNewLines.length) {
let deltaLinesNew = fileNewLines
.slice(limits.extStart2 - 1, start2 - 1)
.map((l) => ` ${l}`);
if (!arraysEqual(deltaLinesOriginal, deltaLinesNew)) {
let foundMiniMatch = false;
for (let k = 0; k < deltaLinesOriginal.length; k++) {
if (arraysEqual(deltaLinesOriginal.slice(k), deltaLinesNew.slice(k))) {
deltaLinesOriginal = deltaLinesOriginal.slice(k);
deltaLinesNew = deltaLinesNew.slice(k);
limits.extStart1 += k;
limits.extSize1 -= k;
limits.extStart2 += k;
limits.extSize2 -= k;
foundMiniMatch = true;
break;
}
}
if (!foundMiniMatch) {
limits = {
extStart1: start1,
extSize1: size1,
extStart2: start2,
extSize2: size2,
};
deltaLinesOriginal = [];
}
}
}
if (sectionHeader && !allowDynamicContext) {
for (const l of deltaLinesOriginal) {
if (l.includes(sectionHeader)) {
sectionHeader = "";
break;
}
}
}
extendedPatchLines.push(
`@@ -${limits.extStart1},${limits.extSize1} +${limits.extStart2},${limits.extSize2} @@ ${sectionHeader}`,
);
extendedPatchLines.push(...deltaLinesOriginal);
continue;
} else {
extendedPatchLines.push(
`@@ -${start1},${size1} +${start2},${size2} @@ ${sectionHeader}`,
);
continue;
}
}
}
extendedPatchLines.push(line);
}
if (start1 !== -1 && patchExtraLinesAfter > 0 && isValidHunk) {
const delta = fileOriginalLines.slice(
start1 + size1 - 1,
start1 + size1 - 1 + patchExtraLinesAfter,
);
extendedPatchLines.push(...delta.map((l) => ` ${l}`));
}
return extendedPatchLines.join("\n");
}
function arraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
function checkHunkLinesMatch(
i: number,
originalLines: string[],
patchLines: string[],
start1: number,
): boolean {
try {
if (i + 1 < patchLines.length && patchLines[i + 1][0] === " ") {
if (patchLines[i + 1].trim() !== (originalLines[start1 - 1] ?? "").trim()) {
return false;
}
}
} catch {
// ignore
}
return true;
}
// ── convert patch hunks to line-numbered format ───────────────────────────
export function decoupleAndConvertToHunksWithLinesNumbers(
patch: string,
file: FilePatchInfo | null,
): string {
let out = "";
if (file) {
if (file.editType === EditType.DELETED) {
return `\n\n## File '${file.filename.trim()}' was deleted\n`;
}
out = `\n\n## File: '${file.filename.trim()}'\n`;
}
const patchLines = patch.split("\n");
let newContentLines: string[] = [];
let oldContentLines: string[] = [];
let match: HunkHeader | null = null;
let start2 = -1;
let prevHeaderLine = "";
let headerLine = "";
function flushHunk(isLast = false) {
if (match && (newContentLines.length || oldContentLines.length)) {
if (!isLast) out += `\n${prevHeaderLine}\n`;
const isPlus = newContentLines.some((l) => l.startsWith("+"));
const isMinus = oldContentLines.some((l) => l.startsWith("-"));
if (isPlus || isMinus) {
out = out.replace(/\s*$/, "") + "\n__new hunk__\n";
for (let i = 0; i < newContentLines.length; i++) {
out += `${start2 + i} ${newContentLines[i]}\n`;
}
}
if (isMinus) {
out = out.replace(/\s*$/, "") + "\n__old hunk__\n";
for (const l of oldContentLines) out += `${l}\n`;
}
newContentLines = [];
oldContentLines = [];
}
}
for (let lineI = 0; lineI < patchLines.length; lineI++) {
const line = patchLines[lineI];
if (line.toLowerCase().includes("no newline at end of file")) continue;
if (line.startsWith("@@")) {
headerLine = line;
const m = parseHunkHeader(line);
if (m && (newContentLines.length || oldContentLines.length)) {
flushHunk();
}
if (m) {
prevHeaderLine = headerLine;
start2 = m.start2;
}
match = m;
} else if (line.startsWith("+")) {
newContentLines.push(line);
} else if (line.startsWith("-")) {
oldContentLines.push(line);
} else {
if (!line && lineI) {
if (lineI + 1 < patchLines.length && patchLines[lineI + 1].startsWith("@@")) continue;
if (lineI + 1 === patchLines.length) continue;
}
newContentLines.push(line);
oldContentLines.push(line);
}
}
flushHunk(true);
return out.trimEnd();
}
// ── full diff assembly (token budget) ─────────────────────────────────────
export interface PrDiffResult {
diff: string;
remainingFiles: string[];
}
const DELETED_FILES_ = "Deleted files:\n";
const MORE_MODIFIED_FILES_ = "Additional modified files (insufficient token budget to process):\n";
const ADDED_FILES_ = "Additional added files (insufficient token budget to process):\n";
export function generateFullPatch(
fileDict: { filename: string; patch: string; tokens: number; editType: EditType }[],
maxTokensModel: number,
promptTokens: number,
cfg: Config,
convertHunksToLineNumbers: boolean,
): { patches: string[]; totalTokens: number; remainingFiles: string[]; filesInPatch: string[] } {
let totalTokens = promptTokens;
const patches: string[] = [];
const remainingFiles: string[] = [];
const filesInPatch: string[] = [];
// For the budget test path, treat "0" soft/hard as unlimited buffer.
const soft = cfg.outputBufferSoftThreshold || 0;
const hard = cfg.outputBufferHardThreshold || 0;
for (const data of fileDict) {
const hardThreshold = Math.max(maxTokensModel - hard, 0);
const softThreshold = Math.max(maxTokensModel - soft, 0);
if (totalTokens > hardThreshold) {
remainingFiles.push(data.filename);
continue;
}
if (totalTokens + data.tokens > softThreshold) {
// soft > maxTokensModel means the soft threshold is effectively
// unlimited (Python uses prompt_tokens + max_model_tokens math that
// never trips for normal configs); treat as "can fit".
if (soft >= maxTokensModel || soft <= 0) {
patches.push(data.patch);
totalTokens += data.tokens;
filesInPatch.push(data.filename);
continue;
}
remainingFiles.push(data.filename);
continue;
}
let patchFinal: string;
if (!convertHunksToLineNumbers) {
patchFinal = `\n\n## File: '${data.filename.trim()}'\n\n${data.patch.trim()}\n`;
} else {
patchFinal = "\n\n" + data.patch.trim();
}
patches.push(patchFinal);
totalTokens += countTokens(patchFinal);
filesInPatch.push(data.filename);
}
return { patches, totalTokens, remainingFiles, filesInPatch };
}
export function getPrDiff(
files: FilePatchInfo[],
promptTokens: number,
model: string,
cfg: Config,
): PrDiffResult {
// extended patch pass
const patchesExtended: string[] = [];
let totalTokens = promptTokens;
for (const file of files) {
if (!file.patch) continue;
const extended = extendPatch(
file.patch,
file.baseFile,
cfg.patchExtraLinesBefore,
cfg.patchExtraLinesAfter,
file.filename,
file.headFile,
cfg,
);
const tokens = countTokens(extended);
totalTokens += tokens;
patchesExtended.push(extended);
}
const maxTokensModel = getModelTokenLimitLocal(model, cfg);
if (totalTokens + cfg.outputBufferSoftThreshold < maxTokensModel) {
return { diff: patchesExtended.join("\n"), remainingFiles: [] };
}
// compressed pass
const fileDict: { filename: string; patch: string; tokens: number; editType: EditType }[] = [];
const deletedFiles: string[] = [];
for (const file of files) {
// files with no patch or deleted are skipped from patch list
const patched = handlePatchDeletions(
file.patch,
file.baseFile,
file.headFile,
file.filename,
file.editType,
);
if (patched === null) {
if (!deletedFiles.includes(file.filename)) deletedFiles.push(file.filename);
continue;
}
if (!patched) continue;
// skip invalid/generated files
if (isGeneratedOrInvalidFile(file.filename)) continue;
// convert to line-numbered hunks
const converted = decoupleAndConvertToHunksWithLinesNumbers(patched, file);
const tokens = countTokens(converted);
fileDict.push({ filename: file.filename, patch: converted, tokens, editType: file.editType });
}
const { patches, totalTokens: totalTokensNew, remainingFiles, filesInPatch } =
generateFullPatch(fileDict, maxTokensModel, promptTokens, cfg, true);
// added/modified/deleted file lists
const maxTokensForLists = maxTokensModel - cfg.outputBufferHardThreshold;
let currToken = totalTokensNew;
let finalDiff = patches.join("\n");
const addedList: string[] = [];
const modifiedList: string[] = [];
const deletedList: string[] = [];
const deltaTokens = 10;
if (maxTokensForLists - currToken > deltaTokens) {
// NOTE: patches may be empty if even a single file exceeds the budget.
// In that case the Python version also returns just the lists (empty diff
// body), which is the expected edge behavior. We keep that.
}
let addedStr = clipTokens(ADDED_FILES_ + addedList.join("\n"), maxTokensForLists - currToken);
if (addedStr) {
finalDiff += "\n\n" + addedStr;
currToken += countTokens(addedStr) + 2;
}
let modifiedStr = clipTokens(MORE_MODIFIED_FILES_ + modifiedList.join("\n"), maxTokensForLists - currToken);
if (modifiedStr) {
finalDiff += "\n\n" + modifiedStr;
currToken += countTokens(modifiedStr) + 2;
}
let deletedStr = clipTokens(DELETED_FILES_ + deletedList.join("\n"), maxTokensForLists - currToken);
if (deletedStr) finalDiff += "\n\n" + deletedStr;
return { diff: finalDiff, remainingFiles };
}
function getModelTokenLimitLocal(model: string, cfg: Config): number {
// circular import avoidance — small local copy of the table
const table: Record<string, number> = {
"claude-opus-5": 1000000,
"claude-sonnet-5": 1000000,
"claude-haiku-4-5-20251001": 200000,
};
let limit = table[model];
if (limit === undefined) limit = cfg.customModelMaxTokens > 0 ? cfg.customModelMaxTokens : 128000;
if (cfg.maxModelTokens > 0) limit = Math.min(cfg.maxModelTokens, limit);
return limit;
}
export function clipTokens(
text: string,
maxTokens: number,
numInputTokens?: number,
addThreeDots = true,
): string {
if (!text || maxTokens < 0) return "";
if (maxTokens === 0) return "";
const inputTokens = numInputTokens ?? countTokens(text);
if (inputTokens <= maxTokens) return text;
// approximate chars/token from actual
const ratio = text.length / Math.max(1, inputTokens);
const targetChars = Math.floor(maxTokens * ratio * 0.9);
let clipped = text.slice(0, targetChars);
if (addThreeDots) clipped += "\n...(truncated)";
return clipped;
}
// ── language-based ordering (simplified but deterministic) ────────────────
export function sortFilesByMainLanguages(
languages: Record<string, number>,
files: FilePatchInfo[],
): FilePatchInfo[] {
const langList = Object.keys(languages).sort((a, b) => languages[b] - languages[a]);
const extensionFor: Record<string, string> = {
py: "Python", ts: "TypeScript", tsx: "TypeScript", js: "JavaScript",
jsx: "JavaScript", go: "Go", rs: "Rust", java: "Java", kt: "Kotlin",
rb: "Ruby", php: "PHP", cs: "C#", cpp: "C++", c: "C", h: "C",
vue: "Vue", swift: "Swift", scala: "Scala", html: "HTML", css: "CSS",
scss: "SCSS", sh: "Shell", bash: "Shell", yml: "YAML", yaml: "YAML",
json: "JSON", toml: "TOML", md: "Markdown", dockerfile: "Dockerfile",
};
const fileLangs: { file: FilePatchInfo; lang: string }[] = files.map((f) => {
const ext = f.filename.split(".").pop()?.toLowerCase() ?? "";
const lang = extensionFor[ext] ?? "Other";
return { file: f, lang };
});
// order: main languages first (by size), then files of that lang, then Other
const ordered: FilePatchInfo[] = [];
for (const lang of langList) {
const bucket = fileLangs.filter((x) => x.lang === lang);
if (bucket.length) ordered.push(...bucket.map((x) => x.file));
}
ordered.push(...fileLangs.filter((x) => !langList.includes(x.lang)).map((x) => x.file));
return ordered;
}
+471
View File
@@ -0,0 +1,471 @@
// GitHub App provider — port of pr_agent.git_providers.github_provider.
// Uses @octokit/auth-app for JWT→installation token and @octokit/rest for the
// REST calls the review pipeline needs. Rate-limit-aware retry.
import { createAppAuth, type AppAuthentication } from "@octokit/auth-app";
import { Octokit } from "@octokit/rest";
import type { Config } from "./config";
import { EditType, type FilePatchInfo } from "./diff";
import { isGeneratedOrInvalidFile } from "./diff";
// NOTE: with Bun we must NOT pass a Promise-returning `auth()` to Octokit —
// @octokit/core's token authStrategy expects a STRING and calls .then on the
// returned value. Instead we pass a custom authStrategy that injects a
// `Bearer <installationToken>` header (updated lazily).
export interface PullRequestData {
number: number;
title: string;
body: string;
state: string;
head: { ref: string; sha: string; repo?: { name: string; owner?: { login: string } } };
base: { ref: string; sha: string };
htmlUrl: string;
additions: number;
deletions: number;
changedFiles: number;
}
export interface GhComment {
id: number;
body: string;
htmlUrl: string;
createdAt: string;
}
const MAX_FILES_ALLOWED_FULL = 50;
export class GitHubProvider {
private octokit: Octokit;
private appClient!: Octokit;
private appAuth: ReturnType<typeof createAppAuth>;
private auth: AppAuthentication | null = null;
private authExpiresAt = 0;
private installationToken: string | null = null;
readonly repo: string; // owner/name
readonly prNumber: number;
private pr: PullRequestData | null = null;
private diffs: FilePatchInfo[] | null = null;
private repoObjCache: { languages?: Record<string, number> } = {};
constructor(
private cfg: Config,
repoOwner: string,
repoName: string,
prNumber: number,
privateKeyPem: string,
) {
this.repo = `${repoOwner}/${repoName}`;
this.prNumber = prNumber;
const baseUrl = cfg.github.baseUrl;
this.appAuth = createAppAuth({
appId: cfg.github.appId,
privateKey: privateKeyPem,
});
// App-level client: uses JWT (type: 'app'), only for discovering
// installation id (GET /repos/{owner}/{repo}/installation needs app JWT).
this.appClient = new Octokit({
baseUrl,
authStrategy: () => ({
hook: async (request: any, options: Record<string, any>) => {
const jwtAuth = await this.appAuth({ type: "app" });
options.headers = options.headers ?? {};
options.headers.authorization = `Bearer ${jwtAuth.token}`;
options.headers["x-github-api-version"] = "2022-11-28";
return request(options);
},
}),
request: { timeout: 20_000 },
});
// Installation-level client: bearer token per repo.
this.octokit = new Octokit({
baseUrl,
authStrategy: this.installTokenStrategy.bind(this),
throttle: {
enabled: true,
onRateLimit: () => true,
onSecondaryRateLimit: () => true,
},
request: { timeout: 20_000 },
});
}
// Custom auth strategy: inject `Authorization: Bearer <installation token>`
// lazily on every request (token refreshed on expiry).
private installTokenStrategy(): { hook: (request: unknown, options: Record<string, any>) => unknown } {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hook: (request: any, options: { headers?: Record<string, string>; [k: string]: any }) => {
const token = this.installationToken;
if (!token) {
// force fetch (async); can't await in hook — pre-fetch synchronously
void this.getInstallationToken().then((t) => {
options.headers = options.headers ?? {};
options.headers.authorization = `Bearer ${t}`;
});
} else {
options.headers = options.headers ?? {};
options.headers.authorization = `Bearer ${token}`;
}
return request(options);
},
};
}
private async getInstallationToken(): Promise<string> {
// refresh ~1 minute before expiry
if (this.installationToken && this.authExpiresAt && Date.now() < this.authExpiresAt - 60_000) {
return this.installationToken;
}
const [owner, repo] = this.repo.split("/");
// list repository installations to discover installation id (uses app JWT)
const { data: installs } = await fetchInstallations(this.appClient, owner, repo, this.cfg);
const inst = installs[0];
if (!inst) throw new Error(`No GitHub App installation for ${this.repo}`);
const auth = await this.appAuth({ type: "installation", installationId: inst.id });
this.auth = auth as unknown as AppAuthentication;
this.authExpiresAt = Date.now() + new Date(auth.expiresAt).getTime() - Date.now() - 60_000;
this.installationToken = this.auth.token;
return this.installationToken;
}
async ensureInstallationToken(): Promise<string> {
return this.getInstallationToken();
}
async getPr(): Promise<PullRequestData> {
if (this.pr) return this.pr;
await this.ensureInstallationToken();
const [owner, repo] = this.repo.split("/");
const { data } = await this.retry(() =>
this.octokit.pulls.get({ owner, repo, pull_number: this.prNumber }),
);
this.pr = {
number: data.number,
title: data.title,
body: data.body ?? "",
state: data.state ?? "",
head: {
ref: data.head.ref,
sha: data.head.sha,
repo: {
name: data.head.repo?.name,
owner: { login: data.head.repo?.owner?.login },
},
},
base: { ref: data.base.ref, sha: data.base.sha },
htmlUrl: data.html_url,
additions: data.additions ?? 0,
deletions: data.deletions ?? 0,
changedFiles: data.changed_files ?? 0,
};
return this.pr;
}
async getPrDescription(full = true): Promise<string> {
await this.ensureInstallationToken();
const pr = await this.getPr();
return (full ? pr.body : pr.body) || "";
}
async getTitle(): Promise<string> {
const pr = await this.getPr();
return pr.title;
}
async getPrBranch(): Promise<string> {
await this.ensureInstallationToken();
const pr = await this.getPr();
return pr.head.ref;
}
async getCommits(): Promise<string[]> {
await this.ensureInstallationToken();
const [owner, repo] = this.repo.split("/");
const { data } = await this.retry(() =>
this.octokit.pulls.listCommits({ owner, repo, pull_number: this.prNumber, per_page: 100 }),
);
return data.map((c) => c.commit?.message ?? "");
}
async getCommitMessagesStr(maxTokens: number): Promise<string> {
const messages = await this.getCommits();
const str = messages.map((m, i) => `${i + 1}. ${m}`).join("\n");
return str; // caller clips with maxTokens
}
async getLanguages(): Promise<Record<string, number>> {
await this.ensureInstallationToken();
const [owner, repo] = this.repo.split("/");
const resp = await this.retry(() =>
this.octokit.rest.repos.listLanguages({ owner, repo }),
);
return resp.data as unknown as Record<string, number>;
}
async getRepoFileContent(path: string, ref: string): Promise<string> {
const [owner, repo] = this.repo.split("/");
try {
const { data } = await this.retry(() =>
this.octokit.repos.getContent({ owner, repo, path, ref }),
);
if (Array.isArray(data)) return "";
if (!("content" in data) || typeof data.content !== "string") return "";
return Buffer.from(data.content, "base64").toString("utf-8");
} catch {
return "";
}
}
async getMergeBaseSha(): Promise<string> {
const pr = await this.getPr();
const [owner, repo] = this.repo.split("/");
try {
const { data } = await this.retry(() =>
this.octokit.repos.compareCommits({
owner,
repo,
base: pr.base.sha,
head: pr.head.sha,
}),
);
return data.merge_base_commit?.sha ?? pr.base.sha;
} catch {
return pr.base.sha;
}
}
async getDiffFiles(): Promise<FilePatchInfo[]> {
await this.ensureInstallationToken();
if (this.diffs) return this.diffs;
const pr = await this.getPr();
const mergeBaseSha = await this.getMergeBaseSha();
const [owner, repo] = this.repo.split("/");
const { data: files } = await this.retry(() =>
this.octokit.pulls.listFiles({ owner, repo, pull_number: this.prNumber, per_page: 100 }),
);
const diffFiles: FilePatchInfo[] = [];
let counterValid = 0;
for (const f of files) {
const filename = f.filename;
if (!filename || isGeneratedOrInvalidFile(filename)) continue;
let patch = f.patch ?? "";
let newContent = "";
let baseContent = "";
counterValid++;
const avoidLoad = counterValid >= MAX_FILES_ALLOWED_FULL && patch.length > 0;
if (!avoidLoad) {
newContent = await this.getRepoFileContent(filename, pr.head.sha);
baseContent = await this.getRepoFileContent(filename, mergeBaseSha);
}
if (!patch) {
// build diff from base/head
patch = buildLargeDiff(filename, baseContent, newContent);
}
if (!patch) continue;
let editType: EditType;
switch (f.status) {
case "added": editType = EditType.ADDED; break;
case "removed": editType = EditType.DELETED; break;
case "renamed": editType = EditType.RENAMED; break;
default: editType = EditType.MODIFIED;
}
const numPlus = f.additions ?? 0;
const numMinus = f.deletions ?? 0;
diffFiles.push({
filename,
baseFile: baseContent,
headFile: newContent,
patch,
editType,
numPlusLines: numPlus,
numMinusLines: numMinus,
});
}
this.diffs = diffFiles;
return diffFiles;
}
// ── publishing ──────────────────────────────────────────────────────────
async publishComment(body: string, isTemporary = false): Promise<GhComment> {
await this.ensureInstallationToken();
const [owner, repo] = this.repo.split("/");
const { data } = await this.retry(() =>
this.octokit.issues.createComment({
owner,
repo,
issue_number: this.prNumber,
body,
}),
);
return {
id: data.id,
body: data.body ?? "",
htmlUrl: data.html_url,
createdAt: data.created_at,
};
}
async editComment(commentId: number, body: string): Promise<void> {
const [owner, repo] = this.repo.split("/");
await this.retry(() =>
this.octokit.issues.updateComment({ owner, repo, comment_id: commentId, body }),
);
}
async deleteComment(commentId: number): Promise<void> {
const [owner, repo] = this.repo.split("/");
await this.retry(() =>
this.octokit.issues.deleteComment({ owner, repo, comment_id: commentId }),
);
}
async listIssueComments(): Promise<GhComment[]> {
const [owner, repo] = this.repo.split("/");
const { data } = await this.retry(() =>
this.octokit.issues.listComments({ owner, repo, issue_number: this.prNumber, per_page: 100 }),
);
return data.map((c) => ({
id: c.id,
body: c.body ?? "",
htmlUrl: c.html_url,
createdAt: c.created_at,
}));
}
async addLabels(labelNames: string[]): Promise<void> {
const [owner, repo] = this.repo.split("/");
if (!labelNames.length) return;
await this.retry(() =>
this.octokit.issues.addLabels({ owner, repo, issue_number: this.prNumber, labels: labelNames }),
);
}
async getPrUrl(): Promise<string> {
const pr = await this.getPr();
return pr.htmlUrl;
}
/** Persistent comment: find a previous comment starting with `header` and
* update it in place; else create new. Mirrors pr_agent behavior. */
async publishPersistentComment(
content: string,
initialHeader: string,
name = "review",
finalUpdateMessage = true,
): Promise<void> {
const comments = await this.listIssueComments();
for (const c of comments) {
if (c.body.startsWith(initialHeader)) {
const latestCommitUrl = await this.getLatestCommitUrl();
const updatedHeader = `${initialHeader}\n\n#### (${name.charAt(0).toUpperCase() + name.slice(1)} updated until commit ${latestCommitUrl})\n`;
const updated = c.body
? content.replace(initialHeader, updatedHeader)
: content;
await this.editComment(c.id, updated);
if (finalUpdateMessage) {
await this.publishComment(
`**[Persistent ${name}](<${c.htmlUrl}>)** updated to latest commit [${latestCommitUrl}](${latestCommitUrl})`,
);
}
return;
}
}
await this.publishComment(content);
}
async getLatestCommitUrl(): Promise<string> {
const pr = await this.getPr();
return pr.head.sha;
}
async removeInitialComment(initialBodyContains: string): Promise<void> {
const comments = await this.listIssueComments();
for (const c of comments) {
if (c.body.includes(initialBodyContains) && c.body.includes("Preparing review")) {
await this.deleteComment(c.id);
}
}
}
private async retry<T>(fn: () => Promise<T>): Promise<T> {
let lastErr: unknown;
for (let attempt = 0; attempt < this.cfg.github.rateLimitRetries; attempt++) {
try {
return await fn();
} catch (e) {
lastErr = e;
const err = e as { status?: number; message?: string };
// retry only on rate limit-ish errors
if (err.status === 403 || err.status === 429 || (err.message ?? "").includes("rate limit")) {
const delay =
this.cfg.github.rateLimitDelaySec * Math.pow(2, attempt) * 1000 +
Math.random() * 1000;
await new Promise((r) => setTimeout(r, delay));
continue;
}
throw e;
}
}
throw lastErr;
}
}
async function fetchInstallations(
octokit: Octokit,
owner: string,
repo: string,
cfg: Config,
): Promise<{ data: { id: number }[] }> {
// try direct repo-scoped lookup first: GET /repos/{owner}/{repo}/installation
try {
const { data } = await octokit.request("GET /repos/{owner}/{repo}/installation", {
owner,
repo,
});
return { data: [{ id: (data as { id: number }).id }] };
} catch {
// fall back to listing app installations and filtering by repo
const { data } = await octokit.request("GET /app/installations", {
per_page: 100,
});
const insts: { id: number }[] = [];
for (const inst of data as { id: number; account?: { login?: string } }[]) {
try {
const resp = await octokit.request("GET /installation/repositories", {
per_page: 100,
});
const found = (
resp.data as unknown as { repositories: { full_name?: string }[] }
).repositories.some((r) => r.full_name === `${owner}/${repo}`);
if (found) insts.push({ id: inst.id });
} catch {
// skip
}
}
return { data: insts };
}
}
function buildLargeDiff(filename: string, baseContent: string, headContent: string): string {
if (!baseContent && !headContent) return "";
if (baseContent === headContent) return "";
const baseLines = baseContent.split("\n");
const headLines = headContent.split("\n");
// Simple whole-file diff: present as full add or full delete
if (!baseContent) {
return `@@ -0,0 +1,${headLines.length} @@\n${headLines.map((l) => "+" + l).join("\n")}`;
}
if (!headContent) {
return `@@ -1,${baseLines.length} +0,0 @@\n${baseLines.map((l) => "-" + l).join("\n")}`;
}
// fallback: whole-file replace (approximation)
return `@@ -1,${baseLines.length} +1,${headLines.length} @@\n${baseLines
.map((l) => "-" + l)
.concat(headLines.map((l) => "+" + l))
.join("\n")}`;
}
+227
View File
@@ -0,0 +1,227 @@
// Webhook server — GitHub App webhook endpoint + health + analytics.
// Port of the Python run_server.py mounting pr_agent's router: we handle the
// pull_request webhook ourselves, verify HMAC, and dispatch to runReview.
import { createHmac, timingSafeEqual } from "node:crypto";
import type { Config } from "./config";
import { loadConfig } from "./config";
import { runReview } from "./review";
export interface WebhookEnv {
cfg: Config;
privateKeyPem: string;
webhookSecret: string;
analyticsDir: string;
discordWebhookUrl: string;
discordAlertWebhookUrl: string;
}
export async function handleWebhook(
env: WebhookEnv,
body: string,
signatureHeader: string | null,
event: string,
): Promise<{ status: number; body: unknown }> {
// HMAC verification (sha256)
if (!signatureHeader) {
return { status: 403, body: { error: "missing signature" } };
}
const sig = signatureHeader.replace(/^sha256=/i, "");
const expected = createHmac("sha256", env.webhookSecret).update(body).digest("hex");
const a = Buffer.from(sig, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return { status: 403, body: { error: "invalid signature" } };
}
if (event !== "pull_request") {
return { status: 200, body: { ok: true, ignored: true } };
}
let payload: {
action?: string;
pull_request?: {
number?: number;
state?: string;
url?: string;
draft?: boolean;
labels?: { name?: string }[];
};
installation?: { id?: number };
};
try {
payload = JSON.parse(body);
} catch {
return { status: 400, body: { error: "invalid JSON" } };
}
const pr = payload.pull_request;
if (!pr || !pr.number || pr.state !== "open" || pr.draft) {
return { status: 200, body: { ok: true, ignored: true } };
}
// extract owner/repo from the pull_request.url (api.github.com/repos/{o}/{r}/pulls/{n})
let owner = "";
let repo = "";
if (pr.url) {
const m = /\/repos\/([^/]+)\/([^/]+)\/pulls\//.exec(pr.url);
if (m) {
owner = m[1];
repo = m[2];
}
}
if (!owner || !repo) {
return { status: 200, body: { ok: true, ignored: true, error: "no repo" } };
}
// 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(
`${env.analyticsDir}/pr-agent.bun.jsonl`,
JSON.stringify({
time: new Date().toISOString(),
repo: `${owner}/${repo}`,
pr: pr.number,
command: "review",
model: result.model,
prompt_tokens: result.promptTokens,
completion_tokens: result.completionTokens,
cached_tokens: result.cachedTokens,
markdown_len: result.markdown.length,
}) + "\n",
);
} catch {
// ignore
}
}
if (result.markdown && env.discordWebhookUrl) {
void sendDiscord(
env.discordWebhookUrl,
`**${owner}/${repo}** PR #${pr.number} reviewed` +
(result.data && result.data["review"] && (result.data["review"] as Record<string, unknown>)["score"]
? ` — score ${(result.data["review"] as Record<string, unknown>)["score"]}/10`
: "") +
`\n${result.markdown.slice(0, 4000)}`,
"✅ PR-Agent Review Complete",
);
}
})
.catch((e) => {
if (env.discordAlertWebhookUrl) {
void sendDiscord(
env.discordAlertWebhookUrl,
`**${owner}/${repo}** PR #${pr.number} review FAILED\n\`\`\`${String((e as Error).message)}\`\`\``,
"🚨 PR-Agent Review Failed",
);
}
});
return { status: 200, body: { ok: true, triggered: true } };
}
function awaitLocalFs() {
// dynamic import to avoid loading fs at module scope
return import("node:fs");
}
let _discordClient: unknown = null;
async function getHttpx() {
// minimal: use global fetch
return fetch;
}
async function sendDiscord(webhook: string, content: string, title: string): Promise<void> {
try {
await fetch(webhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: "PR-Agent Ops",
embeds: [{ title, description: content.slice(0, 4000), color: 0x5865f2 }],
}),
});
} catch {
// never raise
}
}
// ── server ────────────────────────────────────────────────────────────────
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 webhookSecret =
env?.webhookSecret ?? process.env.GITHUB_WEBHOOK_SECRET ?? "";
const analyticsDir =
env?.analyticsDir ?? (process.env.PR_AGENT_ANALYTICS_DIR || "/var/lib/pr-agent-server/analytics");
const discordWebhookUrl =
env?.discordWebhookUrl ?? process.env.DISCORD_WEBHOOK_URL ?? "";
const discordAlertWebhookUrl =
env?.discordAlertWebhookUrl ?? process.env.DISCORD_ALERT_WEBHOOK_URL ?? "";
const fullEnv: WebhookEnv = {
cfg,
privateKeyPem,
webhookSecret,
analyticsDir,
discordWebhookUrl,
discordAlertWebhookUrl,
};
const server = Bun.serve({
port: Number(process.env.PORT || 3000),
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/health") {
return Response.json({ status: "ok", model: cfg.model });
}
if (url.pathname === "/api/v1/github_webhooks" || url.pathname === "/") {
if (req.method !== "POST") {
return Response.json({ ok: true });
}
const body = await req.text();
const sig = req.headers.get("x-hub-signature-256");
const event = req.headers.get("x-github-event") || "";
const result = await handleWebhook(fullEnv, body, sig, event);
return Response.json(result.body, { status: result.status });
}
if (url.pathname === "/api/metrics") {
return new Response(generateMetrics(), {
headers: { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" },
});
}
if (url.pathname === "/api/analytics") {
return Response.json({ total_events: 0, recent: [], failures: [] });
}
return Response.json({ error: "not found" }, { status: 404 });
},
});
console.log(`PR-Agent Bun server listening on :${server.port}`);
return server;
}
function readPrivateKey(path: string): string {
try {
return require("node:fs").readFileSync(path, "utf-8");
} catch {
return "";
}
}
function generateMetrics(): string {
return [
"# HELP pr_agent_requests_total Total PR-Agent analytics events",
"# TYPE pr_agent_requests_total counter",
'pr_agent_requests_total{status="success"} 0',
'pr_agent_requests_total{status="failed"} 0',
"# HELP pr_agent_requests_by_command PR-Agent events by command",
"# TYPE pr_agent_requests_by_command counter",
].join("\n") + "\n";
}
+132
View File
@@ -0,0 +1,132 @@
// LLM handler — 9router via OpenAI-compatible /chat/completions.
// Reflects pr_agent's LiteLLMAIHandler behavior for claude-opus-5:
// - messages = [{system},{user}]
// - temperature OMITTED for models in NO_SUPPORT_TEMPERATURE_MODELS
// (claude-opus-5, claude-sonnet-5 are in that list)
// - timeout = ai_timeout (600s)
// - non-streaming (claude-opus-5 not in STREAMING_REQUIRED_MODELS)
// - raises on empty content
// Returns { content, finishReason, usage }.
import type { Config } from "./config";
export interface ChatResult {
content: string;
finishReason: string;
usage?: {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
cachedTokens?: number;
};
}
const NO_SUPPORT_TEMPERATURE_MODELS = new Set([
"claude-opus-4-7",
"claude-opus-4-8",
"claude-opus-5",
"anthropic/claude-opus-5",
"claude-sonnet-4-6",
"claude-sonnet-5",
"anthropic/claude-sonnet-5",
"claude-haiku-4-5-20251001",
"anthropic/claude-haiku-4-5-20251001",
"o1", "o1-mini", "o1-preview", "o3", "o3-mini", "o4-mini",
"gpt-5.1-codex", "gpt-5.2-codex", "gpt-5-mini",
"deepseek/deepseek-reasoner",
]);
export async function chatCompletion(
opts: {
model: string;
system: string;
user: string;
temperature?: number;
maxTokens?: number;
cfg: Config;
signal?: AbortSignal;
},
): Promise<ChatResult> {
const { model, system, user, temperature, cfg } = opts;
const messages: Record<string, string>[] = [];
if (system) messages.push({ role: "system", content: system });
messages.push({ role: "user", content: user });
const body: Record<string, unknown> = {
model,
messages,
stream: false,
};
// temperature omitted for models that don't support it (incl. claude-opus-5)
const isNoTemp = NO_SUPPORT_TEMPERATURE_MODELS.has(model);
if (temperature !== undefined && !isNoTemp) {
body["temperature"] = temperature;
}
if (opts.maxTokens) body["max_tokens"] = opts.maxTokens;
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
cfg.aiTimeoutMs || 600000,
);
if (opts.signal) {
opts.signal.addEventListener("abort", () => controller.abort(), { once: true });
}
try {
const resp = await fetch(`${cfg.llm.baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cfg.llm.apiKey}`,
...cfg.llm.extraHeaders,
},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!resp.ok) {
let detail = "";
try {
const errJson = (await resp.json()) as { error?: { message?: string } };
detail = errJson.error?.message || "";
} catch {
detail = await resp.text();
}
throw new Error(
`LLM request failed (${resp.status}): ${detail || resp.statusText}`,
);
}
const data = (await resp.json()) as {
choices?: { message?: { content?: string | null }; finish_reason?: string }[];
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
prompt_tokens_details?: { cached_tokens?: number };
};
};
const choice = data.choices?.[0];
const content = choice?.message?.content ?? "";
if (!content) {
throw new Error(
`Empty content in model response (finish_reason: ${choice?.finish_reason ?? "?"})`,
);
}
return {
content,
finishReason: choice?.finish_reason ?? "stop",
usage: data.usage
? {
promptTokens: data.usage.prompt_tokens,
completionTokens: data.usage.completion_tokens,
totalTokens: data.usage.total_tokens,
cachedTokens: data.usage.prompt_tokens_details?.cached_tokens,
}
: undefined,
};
} finally {
clearTimeout(timeout);
}
}
+194
View File
@@ -0,0 +1,194 @@
// Markdown rendering — port of pr_agent.algo.utils.convert_to_markdown_v2.
// Converts the parsed review YAML into the "# PR Reviewer Guide 🔍" comment.
const EMOJIS: Record<string, string> = {
"Can be split": "🔀",
"Key issues to review": "⚡",
"Recommended focus areas for review": "⚡",
Score: "🏅",
"Relevant tests": "🧪",
"Focused PR": "✨",
"Relevant ticket": "🎫",
"Security concerns": "🔒",
"Todo sections": "📝",
"Insights from user's answers": "📝",
"Code feedback": "🤖",
"Estimated effort to review [1-5]": "⏱️",
"Contribution time cost estimate": "⏳",
"Ticket compliance check": "🎫",
};
export interface ReviewData {
review: Record<string, unknown>;
}
export function convertToMarkdownV2(
outputData: ReviewData,
gfmSupported = true,
enableIntroText = false,
): string {
let md = "## PR Reviewer Guide 🔍\n\n";
const review = outputData.review || {};
if (!Object.keys(review).length) return "";
const todoSummary = (review["todo_summary"] as string) || "";
// pop todo_summary (it's not rendered as a row)
const entries = Object.entries(review).filter(([k]) => k !== "todo_summary");
if (gfmSupported) md += "<table>\n";
for (const [key, value] of entries) {
if (value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0) || (typeof value === "object" && Object.keys(value).length === 0)) {
if (!["can_be_split", "key_issues_to_review"].includes(key.toLowerCase())) continue;
}
const keyNice = key.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
const emoji = EMOJIS[keyNice] || "";
if (keyNice.includes("Estimated effort to review")) {
const k = "Estimated effort to review";
let str = String(value).trim();
let intVal: number;
if (/^\d+$/.test(str)) intVal = parseInt(str, 10);
else {
const m = /^\s*(\d+)/.exec(str);
if (!m) continue;
intVal = parseInt(m[1], 10);
}
const blue = "🔵".repeat(intVal);
const white = "⚪".repeat(Math.max(0, 5 - intVal));
const v = `${intVal} ${blue}${white}`;
if (gfmSupported) md += `<tr><td>${emoji}&nbsp;<strong>${k}</strong>: ${v}</td></tr>\n`;
else md += `### ${emoji} ${k}: ${v}\n\n`;
} else if (keyNice.toLowerCase().includes("relevant tests")) {
const v = String(value).trim().toLowerCase();
if (gfmSupported) {
md += `<tr><td>${emoji}&nbsp;<strong>${isValueNo(v) ? "No relevant tests" : "PR contains tests"}</strong></td></tr>\n`;
} else {
md += `### ${emoji} ${isValueNo(v) ? "No relevant tests" : "PR contains tests"}\n\n`;
}
} else if (keyNice.toLowerCase().includes("ticket compliance check")) {
md += renderTicketCompliance(emoji, value, gfmSupported);
} else if (keyNice.toLowerCase().includes("contribution time cost estimate")) {
const obj = value as Record<string, string>;
const best = expandMinuteSuffix(String(obj["best_case"] ?? ""));
const avg = expandMinuteSuffix(String(obj["average_case"] ?? ""));
const worst = expandMinuteSuffix(String(obj["worst_case"] ?? ""));
if (gfmSupported) {
md += `<tr><td>${emoji}&nbsp;<strong>Contribution time estimate</strong> (best, average, worst case): ${best} | ${avg} | ${worst}</td></tr>\n`;
} else {
md += `### ${emoji} Contribution time estimate (best, average, worst case): ${best} | ${avg} | ${worst}\n\n`;
}
} else if (keyNice.toLowerCase().includes("security concerns")) {
if (isValueNo(String(value))) {
if (gfmSupported) md += `<tr><td>${emoji}&nbsp;<strong>No security concerns identified</strong></td></tr>\n`;
else md += `### ${emoji} No security concerns identified\n\n`;
} else {
const v = emphasizeHeader(String(value).trim());
if (gfmSupported) md += `<tr><td>${emoji}&nbsp;<strong>Security concerns</strong><br><br>\n\n${v}</td></tr>\n`;
else md += `### ${emoji} Security concerns\n\n${v}\n\n`;
}
} else if (keyNice.toLowerCase().includes("todo sections")) {
// todo handled by plain value
if (gfmSupported) {
md += `<tr><td>${emoji}&nbsp;<strong>Todo sections</strong><br><br>\n\n${String(value)}\n</td></tr>\n`;
} else {
md += `### ${emoji} Todo sections\n\n${String(value)}\n\n`;
}
} else if (keyNice.toLowerCase().includes("can be split")) {
// list of sub-prs; render as items
if (gfmSupported) {
md += `<tr><td>${emoji}&nbsp;<strong>Can be split</strong><br><br>\n`;
for (const item of value as { title: string; relevant_files: string[] }[]) {
md += `- **${item.title}**\n`;
for (const f of item.relevant_files) md += ` - \`${f}\`\n`;
}
md += `</td></tr>\n`;
}
} else if (keyNice.toLowerCase().includes("key issues to review")) {
// array of {relevant_file, relevant_line, suggestion} objects (or string)
const items = Array.isArray(value) ? value : [value];
let v = "";
const parts: string[] = [];
for (const it of items) {
if (typeof it === "string") {
parts.push(it);
continue;
}
const o = (it ?? {}) as Record<string, string>;
const file = o["relevant_file"] || "";
const line = o["relevant_line"] || "";
const sug = o["suggestion"] || "";
const loc = file ? `${file}${line ? `:${line}` : ""}` : "";
parts.push(loc ? `**${loc}** — ${sug}` : sug);
}
v = parts.filter(Boolean).join("\n\n") || "No key issues identified";
if (gfmSupported) {
md += `<tr><td>${emoji}&nbsp;<strong>Key issues to review</strong><br><br>\n\n${v}\n</td></tr>\n`;
} else {
md += `### ${emoji} Key issues to review\n\n${v}\n\n`;
}
} else if (keyNice === "Score") {
if (gfmSupported) md += `<tr><td>${emoji}&nbsp;<strong>Score</strong>: ${String(value)}</td></tr>\n`;
else md += `### ${emoji} Score: ${String(value)}\n\n`;
} else if (keyNice.toLowerCase().includes("insights from user's answers")) {
if (gfmSupported) md += `<tr><td>${emoji}&nbsp;<strong>Insights from user's answers</strong><br><br>\n\n${String(value)}\n</td></tr>\n`;
else md += `### ${emoji} Insights from user's answers\n\n${String(value)}\n\n`;
} else {
// generic row
const label = keyNice;
if (gfmSupported) md += `<tr><td>${emoji}&nbsp;<strong>${label}</strong>: ${String(value)}</td></tr>\n`;
else md += `### ${emoji} ${label}: ${String(value)}\n\n`;
}
}
if (gfmSupported) md += "</table>\n";
return md.trimEnd() + "\n";
}
export function isValueNo(value: string): boolean {
const v = value.trim().toLowerCase();
return v === "no" || v === "none" || v === "n/a" || v === "na";
}
export function expandMinuteSuffix(s: string): string {
return s.replace(/(\d+)(m|h|d)/g, "$1 $2");
}
export function emphasizeHeader(s: string): string {
// bold anything before the first ':' if short
const lines = s.split("\n").map((l) => {
const m = /^([^:]{1,60}):(.*)$/.exec(l);
if (m && m[1].trim()) return `**${m[1].trim()}**:${m[2]}`;
return l;
});
return lines.join("\n");
}
function renderTicketCompliance(
emoji: string,
value: unknown,
gfm: boolean,
): string {
const items = Array.isArray(value) ? value : [];
let out = "";
if (items.length === 0) return out;
for (const t of items) {
if (gfm) {
const tObj = t as Record<string, string>;
const url = tObj["ticket_url"] || "";
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()) {
const id = url.trim().split("/").filter(Boolean).pop() || url.trim();
out += `**[${id}](<${url.trim()}>) — ${compliance || "Partially"}**\n\n`;
} else {
out += `**${compliance || "Partially"}**\n\n`;
}
if (explanation?.trim()) out += `${explanation.trim()}\n`;
out += `</td></tr>\n`;
}
}
return out;
}
File diff suppressed because one or more lines are too long
+49
View File
@@ -0,0 +1,49 @@
// Template rendering — Jinja2-like via nunjucks with StrictUndefined.
// pr_agent uses jinja2 Environment(undefined=StrictUndefined); nunjucks
// `Environment` with `throwOnUndefined: true` mimics that (undefined var → throw).
import nunjucks from "nunjucks";
let _env: nunjucks.Environment | null = null;
function getEnv(): nunjucks.Environment {
if (!_env) {
// null loader: we only render from strings
_env = new nunjucks.Environment(null, {
autoescape: false,
throwOnUndefined: true,
});
// nunjucks supports {{ var.key }} dot access, {% if %}, {% for %}, filters.
}
return _env;
}
export function renderTemplate(
template: string,
data: Record<string, unknown>,
): string {
try {
return getEnv().renderString(template, data);
} catch (e) {
// StrictUndefined throws on missing var — pr_agent treats this as a hard
// failure; we rethrow so the caller can react (or fall back).
throw new Error(
`Template render failed (undefined variable?): ${(e as Error).message}`,
);
}
}
/** Render with graceful fallback: if a variable is missing, retry with that
* var filled as an empty string — mirrors pr_agent's tolerant renders in some
* paths. */
export function renderTemplateTolerant(
template: string,
data: Record<string, unknown>,
): string {
try {
return renderTemplate(template, data);
} catch {
// last-resort: strip template tags (used only for very broken edge cases)
return template.replace(/\{\{.*?\}\}/gs, "");
}
}
+242
View File
@@ -0,0 +1,242 @@
// PR Review tool — orchestrates the whole review: fetch PR, build diff,
// render prompts, call LLM, parse YAML, render markdown, publish.
// Port of pr_agent.tools.pr_reviewer.
import type { Config } from "./config";
import { GitHubProvider } from "./github";
import { getPrDiff, sortFilesByMainLanguages } from "./diff";
import { countTokens } from "./token";
import { countPromptTokens } from "./token";
import { renderTemplate } from "./render";
import { REVIEW_SYSTEM_TEMPLATE, REVIEW_USER_TEMPLATE } from "./prompts";
import { loadYaml } from "./yaml";
import { convertToMarkdownV2 } from "./markdown";
import { chatCompletion } from "./llm";
import { getModelTokenLimit as getModelTokenLimitInner } from "./config";
export interface ReviewResult {
markdown: string;
data: Record<string, unknown> | null;
model: string;
promptTokens: number;
completionTokens: number;
cachedTokens: number;
remainingFiles: string[];
diff: string;
status: string;
}
export async function runReview(
cfg: Config,
repoOwner: string,
repoName: string,
prNumber: number,
privateKeyPem: string,
opts?: {
publish?: boolean;
extraInstructions?: string;
},
): Promise<ReviewResult> {
const provider = new GitHubProvider(cfg, repoOwner, repoName, prNumber, privateKeyPem);
const pr = await provider.getPr();
const files = await provider.getDiffFiles();
// main language
let languages: Record<string, number> = {};
try {
languages = await provider.getLanguages();
} catch {
languages = {};
}
const mainLanguage = getMainPrLanguage(languages, files);
const description = await provider.getPrDescription(true);
const branch = await provider.getPrBranch();
const commitMessagesStrRaw = await provider.getCommitMessagesStr(cfg.maxCommitsTokens);
const commitMessagesStr = clipTokensSimple(commitMessagesStrRaw, cfg.maxCommitsTokens);
const title = pr.title;
const date = new Date().toISOString().slice(0, 10);
const r = cfg.prReviewer;
const vars: Record<string, unknown> = {
title,
branch,
description,
language: mainLanguage,
diff: "",
num_pr_files: files.length,
num_max_findings: r.numMaxFindings,
require_score: r.requireScoreReview,
require_tests: r.requireTestsReview,
require_estimate_effort_to_review: r.requireEstimateEffortToReview,
require_estimate_contribution_time_cost: r.requireEstimateContributionTimeCost,
require_can_be_split_review: r.requireCanBeSplitReview,
require_security_review: r.requireSecurityReview,
require_todo_scan: r.requireTodoScan,
question_str: "",
answer_str: "",
extra_instructions: opts?.extraInstructions ?? r.extraInstructions,
skills_context: "",
repo_context: "",
commit_messages_str: commitMessagesStr,
custom_labels: "",
enable_custom_labels: false,
is_ai_metadata: false,
related_tickets: [],
duplicate_prompt_examples: false,
date,
};
// prompt tokens
const promptTokens = countPromptTokens(
REVIEW_SYSTEM_TEMPLATE,
REVIEW_USER_TEMPLATE,
vars,
renderTemplate,
);
// build diff with token budget (reuses prompt tokens)
const { diff, remainingFiles } = getPrDiffWithFiles(files, promptTokens, cfg.model, cfg, vars);
// render final prompts with diff
const systemPrompt = renderTemplate(REVIEW_SYSTEM_TEMPLATE, { ...vars, diff });
const userPrompt = renderTemplate(REVIEW_USER_TEMPLATE, { ...vars, diff });
// LLM call with fallback models
const models = [cfg.model, ...cfg.fallbackModels];
let content = "";
let finishReason = "";
let usedModel = cfg.model;
let usage: { promptTokens: number; completionTokens: number; cachedTokens: number } = {
promptTokens: 0,
completionTokens: 0,
cachedTokens: 0,
};
let lastErr: unknown = null;
for (const model of models) {
try {
const res = await chatCompletion({
model,
system: systemPrompt,
user: userPrompt,
temperature: cfg.temperature,
cfg,
});
content = res.content;
finishReason = res.finishReason;
usedModel = model;
usage = {
promptTokens: res.usage?.promptTokens ?? 0,
completionTokens: res.usage?.completionTokens ?? 0,
cachedTokens: res.usage?.cachedTokens ?? 0,
};
break;
} catch (e) {
lastErr = e;
// if all models fail, throw; else try next
}
}
if (!content) {
throw new Error(`All models failed: ${(lastErr as Error)?.message ?? "unknown"}`);
}
// parse YAML (resilient)
const keysFix = [
"ticket_compliance_check",
"estimated_effort_to_review_[1-5]:",
"security_concerns:",
"key_issues_to_review:",
"relevant_file:",
"relevant_line:",
"suggestion:",
];
let data = loadYaml(content, keysFix, "review", "security_concerns") as Record<string, unknown> | null;
// loadYaml returns { review: ... } — keep as is
if (data && !("review" in data) && "review" in (data as object)) {
data = { review: (data as { review: unknown }).review };
}
// render markdown
const markdown = data && "review" in data
? convertToMarkdownV2(data as { review: Record<string, unknown> }, true, r.enableIntroText)
: "";
// publish
if (opts?.publish !== false) {
if (markdown) {
// persistent comment (replace prev "## PR Reviewer Guide 🔍")
if (r.persistentComment) {
await provider.publishPersistentComment(markdown, "## PR Reviewer Guide 🔍", "review", r.finalUpdateMessage);
} else {
await provider.publishComment(markdown);
}
}
}
return {
markdown,
data,
model: usedModel,
promptTokens: usage.promptTokens || promptTokens,
completionTokens: usage.completionTokens,
cachedTokens: usage.cachedTokens,
remainingFiles,
diff,
status: "success",
};
}
function getPrDiffWithFiles(
files: Parameters<typeof getPrDiff>[0],
promptTokens: number,
model: string,
cfg: Config,
vars: Record<string, unknown>,
): { diff: string; remainingFiles: string[] } {
// Sort by main language first (pushes main-lang files earlier, matching
// pr_agent's pr_generate_extended_diff order; we use the files order as-is
// since the Python version sorts by lang then processes all files equally).
const langs = (vars["_langs"] as Record<string, number>) || {};
const sorted = sortFilesByMainLanguages(langs, files);
// Reuse getPrDiff with our token budget logic
const res = getPrDiff(sorted, promptTokens, model, cfg);
return { diff: res.diff, remainingFiles: res.remainingFiles };
}
function clipTokensSimple(text: string, maxTokens: number): string {
const tokens = countTokens(text);
if (tokens <= maxTokens) return text;
// approximate chars/token from actual ratio
const ratio = text.length / Math.max(1, tokens);
const target = Math.floor(maxTokens * ratio * 0.9);
return text.slice(0, target) + "\n...(truncated)";
}
function getMainPrLanguage(
languages: Record<string, number>,
files: { filename: string }[],
): string {
if (!languages || Object.keys(languages).length === 0 || !files.length) return "";
const top = Object.entries(languages).sort((a, b) => b[1] - a[1])[0][0].toLowerCase();
const ext = files[0].filename.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
python: "Python", typescript: "TypeScript", javascript: "JavaScript",
go: "Go", rust: "Rust", java: "Java", csharp: "C#", cpp: "C++", ruby: "Ruby",
php: "PHP", swift: "Swift", kotlin: "Kotlin", shell: "Shell", html: "HTML",
css: "CSS", vue: "Vue", scala: "Scala",
};
const extLang: Record<string, string> = {
py: "python", ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
go: "go", rs: "rust", java: "java", cs: "csharp", cpp: "cpp", rb: "ruby",
php: "php", swift: "swift", kt: "kotlin", sh: "shell", bash: "shell",
html: "html", css: "css", vue: "vue", scala: "scala",
};
const langOfExt = extLang[ext] || "";
if (langOfExt && langOfExt === top) return map[top] || top;
return langOfExt ? map[langOfExt] || langOfExt : (map[top] || top);
}
export function getModelTokenLimitLocal(model: string, cfg: Config): number {
return getModelTokenLimitInner(model, cfg);
}
+29
View File
@@ -0,0 +1,29 @@
// Bun PR-Agent re-implementation — secrets loader (local dev/test only).
// Reads the same secrets the systemd unit uses; never committed.
import { readFileSync } from "node:fs";
import { join } from "node:path";
export interface Secrets {
appId: number;
privateKey: string;
webhookSecret: string;
omniKey: string;
baseUrl: string;
}
export function loadSecrets(opts?: {
appDir?: string;
appId?: number;
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();
return {
appId: opts?.appId ?? 4319749,
privateKey,
webhookSecret: opts?.webhookSecret ?? "",
omniKey,
baseUrl: process.env.PR_AGENT_BASE_URL ?? "https://9router.asepharyana.my.id/v1",
};
}
+50
View File
@@ -0,0 +1,50 @@
// Token counting — port of pr_agent.algo.token_handler using js-tiktoken
// (o200k_base, same as Python tiktoken). The Python version tried an accurate
// Anthropic count_tokens API call when anthropic.key was set; with 9router that
// call is not an Anthropic API, so we always use the local tokenizer estimate
// (which is what the Python server effectively falls back to on failure).
import { getEncoding, type Tiktoken } from "js-tiktoken";
let _encoder: Tiktoken | null = null;
export function getTokenEncoder(): Tiktoken {
if (!_encoder) {
try {
_encoder = getEncoding("o200k_base");
} catch {
// cl100k_base as a safe fallback if o200k unavailable on old bundles
_encoder = getEncoding("cl100k_base");
}
}
return _encoder;
}
export function countTokens(text: string): number {
if (!text) return 0;
try {
return getTokenEncoder().encode(text, "all").length;
} catch {
// approximate: ~4 chars/token
return Math.ceil(text.length / 4);
}
}
/**
* Approximation of pr_agent's TokenHandler.prompt_tokens: renders the system
* and user prompt strings with the given vars (nunjucks), then counts tokens.
*/
export function countPromptTokens(
systemTemplate: string,
userTemplate: string,
vars: Record<string, unknown>,
render: (tmpl: string, data: Record<string, unknown>) => string,
): number {
try {
const system = render(systemTemplate, vars);
const user = render(userTemplate, vars);
return countTokens(system) + countTokens(user);
} catch {
return 0;
}
}
+164
View File
@@ -0,0 +1,164 @@
// YAML loading with pr_agent's resilient fallback chain.
// Port of pr_agent.algo.utils.load_yaml + try_fix_yaml using the `yaml` package.
import * as YAML from "yaml";
export function loadYaml(
responseText: string,
keysFixYaml: string[] = [],
firstKey = "",
lastKey = "",
): Record<string, unknown> | null {
const original = responseText;
let text = responseText.replace(/^\n+/, "").trim();
text = text
.replace(/^yaml/, "")
.replace(/^```yaml/, "")
.replace(/\s*$/, "")
.replace(/```$/, "");
try {
const data = YAML.parse(text);
if (data && typeof data === "object") return data as Record<string, unknown>;
} catch {
// fall through
}
return tryFixYaml(text, keysFixYaml, firstKey, lastKey, original);
}
const KEYS_YAML = [
"relevant line:",
"suggestion content:",
"relevant file:",
"existing code:",
"improved code:",
"label:",
"why:",
"suggestion_summary:",
];
export function tryFixYaml(
responseText: string,
keysFixYaml: string[] = [],
firstKey = "",
lastKey = "",
original = "",
): Record<string, unknown> | null {
let lines = responseText.split("\n");
const keysYaml = [...KEYS_YAML, ...keysFixYaml];
// fallback 1: convert 'key: value' to 'key: |-' multiline
let copy = lines.map((l) => {
for (const key of keysYaml) {
if (l.includes(key) && !l.includes("|")) {
return l.replace(key, `${key} |\n `);
}
}
return l;
});
let data = tryParse(copy.join("\n"));
if (data) return data;
// 1.5: '|\n' → '|2\n'
copy = responseText.replace(/\|\n/g, "|2\n").split("\n");
data = tryParse(copy.join("\n"));
if (data) return data;
// 1.5b: add 4-space indent to lines at depth 2 containing '}'
const copyB = copy.map((l) => {
const initSpace = l.length - l.trimStart().length;
if (initSpace === 2 && !l.includes("|2") && l.includes("}")) {
return " " + l.trimStart();
}
return l;
});
data = tryParse(copyB.join("\n"));
if (data) return data;
// fallback 2: extract yaml fenced block
const snippetPattern = /```(yaml|yml)?([\s\S]*?)```(?=\s*$|")/;
let m = snippetPattern.exec(copy.join("\n"));
if (!m) m = snippetPattern.exec(original);
if (m && m[2]) {
data = tryParse(m[2]);
if (data) return data;
}
// fallback 3: remove leading/trailing braces
let t3 = responseText
.trim()
.replace(/^\{/, "")
.replace(/\}$/, "")
.replace(/:$/, "");
data = tryParse(t3);
if (data) return data;
// fallback 4: extract from first_key to after last_key
if (firstKey && lastKey) {
let idxStart = responseText.indexOf(`\n${firstKey}:`);
if (idxStart === -1) idxStart = responseText.indexOf(`${firstKey}:`);
const idxLast = responseText.lastIndexOf(`${lastKey}:`);
let idxEnd = responseText.indexOf("\n\n", idxLast);
if (idxEnd === -1) idxEnd = responseText.length;
const t4 = responseText
.slice(idxStart, idxEnd)
.trim()
.replace(/^```yaml/, "")
.replace(/^`/, "")
.replace(/`$/, "");
if (t4) {
data = tryParse(t4);
if (data) return data;
}
}
// fallback 5: remove leading '+'
copy = lines.map((l) => (l.startsWith("+") ? " " + l.slice(1) : l));
data = tryParse(copy.join("\n"));
if (data) return data;
// fallback 6: tabs → spaces
if (responseText.includes("\t")) {
data = tryParse(responseText.replace(/\t/g, " "));
if (data) return data;
}
// fallback 7: indent sections for code blocks
const improveSections = ["existing_code:", "improved_code:", "response:", "why:"];
const describeSections = ["description:", "title:", "changes_diagram:", "pr_files:", "pr_ticket:"];
let startLine = -1;
const lines7 = responseText.split("\n").map((l, i) => {
const strip = l.trimEnd();
if (improveSections.some((k) => strip.includes(k)) || describeSections.some((k) => strip.includes(k))) {
startLine = i;
return l;
} else if (
strip.endsWith(": |") || strip.endsWith(": |-") || strip.endsWith(": |2") ||
keysYaml.some((k) => strip.endsWith(k))
) {
startLine = -1;
return l;
} else if (startLine !== -1) {
return " " + l;
}
return l;
});
const t7 = lines7.join("\n").replace(/ \|\n/g, " |2\n");
data = tryParse(t7);
if (data) return data;
// fallback 8: strip leading pipes
data = tryParse(responseText.replace(/^\|\n/, ""));
if (data) return data;
return null;
}
function tryParse(text: string): Record<string, unknown> | null {
try {
const d = YAML.parse(text);
if (d && typeof d === "object") return d as Record<string, unknown>;
} catch {
// ignore
}
return null;
}
+236
View File
@@ -0,0 +1,236 @@
// Tests for diff processing, YAML parsing, token counting, markdown render.
import { describe, expect, test } from "bun:test";
import {
decoupleAndConvertToHunksWithLinesNumbers,
extendPatch,
generateFullPatch,
handlePatchDeletions,
omitDeletionHunks,
parseHunkHeader,
type FilePatchInfo,
EditType,
} from "../src/diff";
import { loadYaml } from "../src/yaml";
import { countTokens } from "../src/token";
import { convertToMarkdownV2 } from "../src/markdown";
import { renderTemplate } from "../src/render";
import { loadConfig, getModelTokenLimit } from "../src/config";
import { REVIEW_SYSTEM_TEMPLATE, REVIEW_USER_TEMPLATE } from "../src/prompts";
describe("diff processing", () => {
test("parseHunkHeader", () => {
expect(parseHunkHeader("@@ -12,4 +13,6 @@ def foo()")).toEqual({
start1: 12,
size1: 4,
start2: 13,
size2: 6,
sectionHeader: "def foo()",
});
expect(parseHunkHeader("@@ -1 +1 @@")).toEqual({
start1: 1,
size1: 1,
start2: 1,
size2: 1,
sectionHeader: "",
});
});
test("omitDeletionHunks drops delete-only hunks", () => {
const patch = [
"@@ -1,3 +1,3 @@",
" line1",
"-old",
"+new",
" line3",
"@@ -5,3 +5,1 @@",
"-a",
"-b",
"+c",
"@@ -10,2 +10,2 @@",
"-gone1",
"-gone2",
].join("\n");
const out = omitDeletionHunks(patch);
// hunks 1 (has +) and 2 (has +) kept; hunk 3 (delete-only) dropped
expect(out).toContain("-old");
expect(out).toContain("+new");
expect(out).toContain("+c");
expect(out).not.toContain("-gone1");
});
test("extendPatch adds context lines with line numbers", () => {
const original = ["a", "b", "c", "d", "e", "f"].join("\n");
const patch = "@@ -2,1 +2,1 @@\n b\n-c\n+d\n";
const extended = extendPatch(patch, original, 1, 1, "test.ts");
// extended hunk includes line before and after
expect(extended).toContain("@@ -1,3 +1,3 @@");
expect(extended).toContain(" a");
expect(extended).toContain(" c");
});
test("decoupleAndConvertToHunksWithLinesNumbers", () => {
const patch = "@@ -1,3 +1,3 @@\n line1\n-old\n+new\n line3\n";
const file: FilePatchInfo = {
filename: "src/x.py",
baseFile: "line1\nold\nline3\n",
headFile: "line1\nnew\nline3\n",
patch,
editType: EditType.MODIFIED,
numPlusLines: 1,
numMinusLines: 1,
};
const out = decoupleAndConvertToHunksWithLinesNumbers(patch, file);
expect(out).toContain("## File: 'src/x.py'");
expect(out).toContain("__new hunk__");
expect(out).toContain("__old hunk__");
// new hunk has numbered lines
expect(out).toContain("1 line1");
expect(out).toContain("2 +new");
expect(out).toContain("-old");
});
test("deleted file renders marker", () => {
const file: FilePatchInfo = {
filename: "gone.py",
baseFile: "x",
headFile: "",
patch: "",
editType: EditType.DELETED,
numPlusLines: 0,
numMinusLines: 1,
};
const out = decoupleAndConvertToHunksWithLinesNumbers("", file);
expect(out).toContain("was deleted");
});
test("generateFullPatch token budget", () => {
const cfg = {
...loadConfig(),
maxModelTokens: 200,
customModelMaxTokens: 200,
// disable soft/hard buffer so every file fits (mirrors test intent)
outputBufferSoftThreshold: 0,
outputBufferHardThreshold: 0,
};
const fileDict = [
{ filename: "a.py", patch: "aaa", tokens: 10, editType: EditType.MODIFIED },
{ filename: "b.py", patch: "bbb", tokens: 10, editType: EditType.MODIFIED },
];
const res = generateFullPatch(fileDict, 100, 5, cfg, true);
expect(res.patches.length).toBe(2);
expect(res.remainingFiles.length).toBe(0);
// with a tiny budget, one file is skipped
const res2 = generateFullPatch(fileDict, 11, 10, cfg, true);
expect(res2.patches.length).toBe(1);
expect(res2.remainingFiles.length).toBe(1);
});
});
describe("yaml", () => {
test("loadYaml parses clean review", () => {
const text = `review:
score: 89
relevant_tests: |
No
key_issues_to_review:
- relevant_file: src/x.py
issue_header: Possible Bug
issue_content: something
start_line: 12
end_line: 14
`;
const data = loadYaml(text, ["review:", "relevant_tests:"], "review", "security_concerns");
expect(data).not.toBeNull();
const review = (data as Record<string, unknown>)["review"] as Record<string, unknown>;
expect(review["score"]).toBe(89);
expect((review["key_issues_to_review"] as unknown[]).length).toBe(1);
});
test("loadYaml recovers from fenced yaml", () => {
const text = "Here is the review:\n```yaml\nreview:\n score: 42\n```\n";
const data = loadYaml(text, [], "review", "security_concerns");
expect((data as Record<string, unknown>)?.["review"]).toBeDefined();
});
});
describe("tokens", () => {
test("countTokens with o200k", () => {
const n = countTokens("Hello world");
expect(n).toBeGreaterThan(0);
expect(n).toBeLessThan(10);
});
});
describe("markdown", () => {
test("convertToMarkdownV2 renders sections", () => {
const md = convertToMarkdownV2(
{
review: {
estimated_effort_to_review: "3",
relevant_tests: "No",
security_concerns: "No",
key_issues_to_review: [],
score: 89,
},
},
true,
true,
);
expect(md).toContain("## PR Reviewer Guide 🔍");
expect(md).toContain("🔵");
expect(md).toContain("No relevant tests");
expect(md).toContain("Score");
});
});
describe("render", () => {
test("nunjucks StrictUndefined-equivalent", () => {
expect(renderTemplate("{% if require_score %}s={{score}}{% endif %}", { require_score: true, score: 89 })).toBe("s=89");
expect(() => renderTemplate("{{missing}}", {})).toThrow();
});
test("review prompts render with real vars", () => {
const vars = {
title: "feat: x",
branch: "main",
description: "desc",
language: "TypeScript",
diff: "diff",
num_pr_files: 2,
num_max_findings: 3,
require_score: true,
require_tests: true,
require_estimate_effort_to_review: true,
require_security_review: true,
require_estimate_contribution_time_cost: false,
require_can_be_split_review: false,
require_todo_scan: false,
question_str: "",
answer_str: "",
extra_instructions: "",
skills_context: "",
repo_context: "",
commit_messages_str: "1. fix",
custom_labels: "",
enable_custom_labels: false,
is_ai_metadata: false,
related_tickets: [],
duplicate_prompt_examples: false,
date: "2026-09-21",
};
const sys = renderTemplate(REVIEW_SYSTEM_TEMPLATE, vars);
expect(sys).toContain("You are PR-Reviewer");
expect(sys).toContain("key_issues_to_review");
const user = renderTemplate(REVIEW_USER_TEMPLATE, { ...vars, diff: "the diff" });
expect(user).toContain("Title: feat: x");
expect(user).toContain("the diff");
});
});
describe("config", () => {
test("model token limit", () => {
const cfg = loadConfig();
expect(getModelTokenLimit("claude-opus-5", cfg)).toBeLessThanOrEqual(128000);
});
});
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext"],
"types": ["bun"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"allowImportingTsExtensions": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}