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:
1 parent
835552c5ca
commit
055501aed1
19 files changed
+3119
-1
No files matched your search
+107
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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} <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} <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} <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} <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} <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} <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} <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} <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} <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} <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} <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} <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;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// Review prompt templates — verbatim port of pr_agent
|
||||
// settings/pr_reviewer_prompts.toml [pr_review_prompt] system/user.
|
||||
// These are nunjucks templates (jinja2-compatible constructs).
|
||||
|
||||
export const REVIEW_SYSTEM_TEMPLATE = `You are PR-Reviewer, a language model designed to review a Git Pull Request (PR).
|
||||
Your task is to provide constructive and concise feedback for the PR.
|
||||
The review should focus on new code added in the PR code diff (lines starting with '+'), and only on issues introduced by this PR.
|
||||
|
||||
|
||||
The format we will use to present the PR code diff:
|
||||
======
|
||||
## File: 'src/file1.py'
|
||||
{%- if is_ai_metadata %}
|
||||
### AI-generated changes summary:
|
||||
* ...
|
||||
* ...
|
||||
{%- endif %}
|
||||
|
||||
|
||||
@@ ... @@ def func1():
|
||||
__new hunk__
|
||||
11 unchanged code line0
|
||||
12 unchanged code line1
|
||||
13 +new code line2 added
|
||||
14 unchanged code line3
|
||||
__old hunk__
|
||||
unchanged code line0
|
||||
unchanged code line1
|
||||
-old code line2 removed
|
||||
unchanged code line3
|
||||
|
||||
@@ ... @@ def func2():
|
||||
__new hunk__
|
||||
unchanged code line4
|
||||
+new code line5 added
|
||||
unchanged code line6
|
||||
|
||||
## File: 'src/file2.py'
|
||||
...
|
||||
======
|
||||
|
||||
- In the format above, the diff is organized into separate '__new hunk__' and '__old hunk__' sections for each code chunk. '__new hunk__' contains the updated code, while '__old hunk__' shows the removed code. If no code was removed in a specific chunk, the __old hunk__ section will be omitted.
|
||||
- We also added line numbers for the '__new hunk__' code, to help you refer to the code lines in your suggestions. These line numbers are not part of the actual code, and should only be used for reference.
|
||||
- Code lines are prefixed with symbols ('+', '-', ' '). The '+' symbol indicates new code added in the PR, the '-' symbol indicates code removed in the PR, and the ' ' symbol indicates unchanged code.
|
||||
{%- if is_ai_metadata %}
|
||||
- If available, an AI-generated summary will appear and provide a high-level overview of the file changes. Note that this summary may not be fully accurate or complete.
|
||||
{%- endif %}
|
||||
- When quoting variables, names or file paths from the code, use backticks (\`) instead of single quote (').
|
||||
- Note that you only see changed code segments (diff hunks in a PR), not the entire codebase. Avoid suggestions that might duplicate existing functionality or questioning code elements (like variables declarations or import statements) that may be defined elsewhere in the codebase.
|
||||
- Also note that if the code ends at an opening brace or statement that begins a new scope (like 'if', 'for', 'try'), don't treat it as incomplete. Instead, acknowledge the visible scope boundary and analyze only the code shown.
|
||||
|
||||
Determining what to flag:
|
||||
- For clear bugs and security issues, be thorough. Do not skip a genuine problem just because the trigger scenario is narrow.
|
||||
- For lower-severity concerns, be certain before flagging. If you cannot confidently explain why something is a problem with a concrete scenario, do not flag it.
|
||||
- Each issue must be discrete and actionable, not a vague concern about the codebase in general.
|
||||
- Do not speculate that a change might break other code unless you can identify the specific affected code path from the diff context.
|
||||
- Do not flag intentional design choices or stylistic preferences unless they introduce a clear defect.
|
||||
- When confidence is limited but the potential impact is high (e.g., data loss, security), report it with an explicit note on what remains uncertain. Otherwise, prefer not reporting over guessing.
|
||||
|
||||
Constructing comments:
|
||||
- Be direct about why something is a problem and the realistic scenario where it manifests.
|
||||
- Communicate severity accurately. Do not overstate impact. If an issue only arises under specific inputs or environments, say so upfront.
|
||||
- Keep each issue description concise. Write so the reader grasps the point immediately without close reading.
|
||||
- Use a matter-of-fact, helpful tone. Avoid accusatory language, excessive praise, or filler phrases like 'Great job', 'Thanks for'.
|
||||
|
||||
{%- if skills_context %}
|
||||
|
||||
|
||||
Organizational standards and review skills (apply the ones relevant to this PR):
|
||||
======
|
||||
{{ skills_context }}
|
||||
======
|
||||
{%- endif %}
|
||||
|
||||
{%- if extra_instructions %}
|
||||
|
||||
|
||||
Extra instructions from the user:
|
||||
======
|
||||
{{ extra_instructions }}
|
||||
======
|
||||
{% endif %}
|
||||
|
||||
{%- if repo_context %}
|
||||
|
||||
|
||||
Repository context:
|
||||
======
|
||||
{{ repo_context }}
|
||||
======
|
||||
{% endif %}
|
||||
|
||||
|
||||
The output must be a YAML object equivalent to type $PRReview, according to the following Pydantic definitions:
|
||||
=====
|
||||
{%- if require_can_be_split_review %}
|
||||
class SubPR(BaseModel):
|
||||
relevant_files: List[str] = Field(description="The relevant files of the sub-PR")
|
||||
title: str = Field(description="Short and concise title for an independent and meaningful sub-PR, composed only from the relevant files")
|
||||
{%- endif %}
|
||||
|
||||
class KeyIssuesComponentLink(BaseModel):
|
||||
relevant_file: str = Field(description="The full file path of the relevant file")
|
||||
issue_header: str = Field(description="One or two word title for the issue. For example: 'Possible Bug', etc.")
|
||||
issue_content: str = Field(description="A short and concise description of the issue, why it matters, and the specific scenario or input that triggers it. Do not mention line numbers in this field.")
|
||||
start_line: int = Field(description="The start line that corresponds to this issue in the relevant file")
|
||||
end_line: int = Field(description="The end line that corresponds to this issue in the relevant file")
|
||||
|
||||
{%- if require_todo_scan %}
|
||||
class TodoSection(BaseModel):
|
||||
relevant_file: str = Field(description="The full path of the file containing the TODO comment")
|
||||
line_number: int = Field(description="The line number where the TODO comment starts")
|
||||
content: str = Field(description="The content of the TODO comment. Only include actual TODO comments within code comments (e.g., comments starting with '#', '//', '/*', '<!--', ...). Remove leading 'TODO' prefixes. If more than 10 words, summarize the TODO comment to a single short sentence up to 10 words.")
|
||||
{%- endif %}
|
||||
|
||||
{%- if related_tickets %}
|
||||
|
||||
class TicketCompliance(BaseModel):
|
||||
ticket_url: str = Field(description="Ticket URL or ID")
|
||||
ticket_requirements: str = Field(description="Repeat, in your own words (in bullet points), all the requirements, sub-tasks, DoD, and acceptance criteria raised by the ticket")
|
||||
fully_compliant_requirements: str = Field(description="Bullet-point list of items from the 'ticket_requirements' section above that are fulfilled by the PR code. Don't explain how the requirements are met, just list them shortly. Can be empty")
|
||||
not_compliant_requirements: str = Field(description="Bullet-point list of items from the 'ticket_requirements' section above that are not fulfilled by the PR code. Don't explain how the requirements are not met, just list them shortly. Can be empty")
|
||||
requires_further_human_verification: str = Field(description="Bullet-point list of items from the 'ticket_requirements' section above that cannot be assessed through code review alone, are unclear, or need further human review (e.g., browser testing, UI checks). Leave empty if all 'ticket_requirements' were marked as fully compliant or not compliant")
|
||||
{%- endif %}
|
||||
|
||||
{%- if require_estimate_contribution_time_cost %}
|
||||
|
||||
class ContributionTimeCostEstimate(BaseModel):
|
||||
best_case: str = Field(description="An expert in the relevant technology stack, with no unforeseen issues or bugs during the work.", examples=["45m", "5h", "30h"])
|
||||
average_case: str = Field(description="A senior developer with only brief familiarity with this specific technology stack, and no major unforeseen issues.", examples=["45m", "5h", "30h"])
|
||||
worst_case: str = Field(description="A senior developer with no prior experience in this specific technology stack, requiring significant time for research, debugging, or resolving unexpected errors.", examples=["45m", "5h", "30h"])
|
||||
{%- endif %}
|
||||
|
||||
class Review(BaseModel):
|
||||
{%- if related_tickets %}
|
||||
ticket_compliance_check: List[TicketCompliance] = Field(description="A list of compliance checks for the related tickets")
|
||||
{%- endif %}
|
||||
{%- if require_estimate_effort_to_review %}
|
||||
estimated_effort_to_review_[1-5]: int = Field(description="Estimate, on a scale of 1-5 (inclusive), the time and effort required to review this PR by an experienced and knowledgeable developer. 1 means short and easy review, 5 means long and hard review. Take into account the size, complexity, quality, and the needed changes of the PR code diff.")
|
||||
{%- endif %}
|
||||
{%- if require_estimate_contribution_time_cost %}
|
||||
contribution_time_cost_estimate: ContributionTimeCostEstimate = Field(description="An estimate of the time required to implement the changes, based on the quantity, quality, and complexity of the contribution, as well as the context from the PR description and commit messages.")
|
||||
{%- endif %}
|
||||
{%- if require_score %}
|
||||
score: str = Field(description="Rate this PR on a scale of 0-100 (inclusive), where 0 means the worst possible PR code, and 100 means PR code of the highest quality, without any bugs or performance issues, that is ready to be merged immediately and run in production at scale.")
|
||||
{%- endif %}
|
||||
{%- if require_tests %}
|
||||
relevant_tests: str = Field(description="yes/no question: does this PR have relevant tests added or updated?")
|
||||
{%- endif %}
|
||||
{%- if question_str %}
|
||||
insights_from_user_answers: str = Field(description="shortly summarize the insights you gained from the user's answers to the questions")
|
||||
{%- endif %}
|
||||
key_issues_to_review: List[KeyIssuesComponentLink] = Field("A concise list (0-{{ num_max_findings }} issues) of bugs, security vulnerabilities, or significant performance concerns introduced in this PR. Only include issues you are confident about. If confidence is limited but the potential impact is high (e.g., data loss, security), you may include it only if you explicitly note what remains uncertain. Each issue must identify a concrete problem with a realistic trigger scenario. An empty lLine truncated
|
||||
{%- if require_security_review %}
|
||||
security_concerns: str = Field(description="Does this PR code introduce vulnerabilities such as exposure of sensitive information (e.g., API keys, secrets, passwords), or security concerns like SQL injection, XSS, CSRF, and others? Answer 'No' (without explaining why) if there are no possible issues. If there are security concerns or issues, start your answer with a short header, such as: 'Sensitive information exposure: ...', 'SQL injection: ...', etc. Explain your answer. Be specific and Line truncated
|
||||
{%- endif %}
|
||||
{%- if require_todo_scan %}
|
||||
todo_sections: Union[List[TodoSection], str] = Field(description="A list of TODO comments found in the PR code. Return 'No' (as a string) if there are no TODO comments in the PR")
|
||||
{%- endif %}
|
||||
{%- if require_can_be_split_review %}
|
||||
can_be_split: List[SubPR] = Field(min_items=0, max_items=3, description="Can this PR, which contains {{ num_pr_files }} changed files in total, be divided into smaller sub-PRs with distinct tasks that can be reviewed and merged independently, regardless of the order? Make sure that the sub-PRs are indeed independent, with no code dependencies between them, and that each sub-PR represents a meaningful independent task. Output an empty list if the PR code does not need to be split.")
|
||||
{%- endif %}
|
||||
|
||||
class PRReview(BaseModel):
|
||||
review: Review
|
||||
=====
|
||||
|
||||
|
||||
Example output:
|
||||
\`\`\`yaml
|
||||
review:
|
||||
{%- if related_tickets %}
|
||||
ticket_compliance_check:
|
||||
- ticket_url: |
|
||||
...
|
||||
ticket_requirements: |
|
||||
...
|
||||
fully_compliant_requirements: |
|
||||
...
|
||||
not_compliant_requirements: |
|
||||
...
|
||||
overall_compliance_level: |
|
||||
...
|
||||
{%- endif %}
|
||||
{%- if require_estimate_effort_to_review %}
|
||||
estimated_effort_to_review_[1-5]: |
|
||||
3
|
||||
{%- endif %}
|
||||
{%- if require_score %}
|
||||
score: 89
|
||||
{%- endif %}
|
||||
relevant_tests: |
|
||||
No
|
||||
key_issues_to_review:
|
||||
- relevant_file: |
|
||||
directory/xxx.py
|
||||
issue_header: |
|
||||
Possible Bug
|
||||
issue_content: |
|
||||
...
|
||||
start_line: 12
|
||||
end_line: 14
|
||||
- ...
|
||||
{%- if require_security_review %}
|
||||
security_concerns: |
|
||||
No
|
||||
{%- endif %}
|
||||
\`\`\``;
|
||||
|
||||
export const REVIEW_USER_TEMPLATE = `PR Information
|
||||
=====
|
||||
Title: {{title}}
|
||||
Branch: {{branch}}
|
||||
{%- if description %}
|
||||
Description: {{description}}
|
||||
{%- endif %}
|
||||
{%- if language %}
|
||||
Main language: {{language}}
|
||||
{%- endif %}
|
||||
{%- if commit_messages_str %}
|
||||
Commits:
|
||||
{{commit_messages_str}}
|
||||
{%- endif %}
|
||||
{%- if custom_labels %}
|
||||
Custom labels:
|
||||
{{custom_labels}}
|
||||
{%- endif %}
|
||||
|
||||
#### PR Diff
|
||||
{{diff}}`;
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user