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:
+8
-1
@@ -6,7 +6,7 @@ whsec*.txt
|
|||||||
.secrets.toml
|
.secrets.toml
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
**/node_modules/**
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
@@ -22,3 +22,10 @@ build/
|
|||||||
# Runtime data
|
# Runtime data
|
||||||
*.log
|
*.log
|
||||||
*.pid
|
*.pid
|
||||||
|
|
||||||
|
# Planning files (session scratch)
|
||||||
|
.planning/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
# Bun TS build artifacts
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
+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;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -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