diff --git a/.gitignore b/.gitignore index 55f32ac..9607cc0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ whsec*.txt .secrets.toml .env .env.* - +**/node_modules/** # Python __pycache__/ *.pyc @@ -22,3 +22,10 @@ build/ # Runtime data *.log *.pid + +# Planning files (session scratch) +.planning/ +.tmp/ + +# Bun TS build artifacts +*.tsbuildinfo diff --git a/server/bun.lock b/server/bun.lock new file mode 100644 index 0000000..cdef49a --- /dev/null +++ b/server/bun.lock @@ -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=="], + } +} diff --git a/server/e2e.ts b/server/e2e.ts new file mode 100644 index 0000000..bd8e9e5 --- /dev/null +++ b/server/e2e.ts @@ -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 --pr +// +// 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); +} \ No newline at end of file diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..ebaf7c8 --- /dev/null +++ b/server/package.json @@ -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" + } +} \ No newline at end of file diff --git a/server/src/cli.ts b/server/src/cli.ts new file mode 100644 index 0000000..4280c59 --- /dev/null +++ b/server/src/cli.ts @@ -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); +}); \ No newline at end of file diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 0000000..469b1c3 --- /dev/null +++ b/server/src/config.ts @@ -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; + }; +} + +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 = { + "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; +} \ No newline at end of file diff --git a/server/src/diff.ts b/server/src/diff.ts new file mode 100644 index 0000000..9f959d5 --- /dev/null +++ b/server/src/diff.ts @@ -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 = { + "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, + files: FilePatchInfo[], +): FilePatchInfo[] { + const langList = Object.keys(languages).sort((a, b) => languages[b] - languages[a]); + const extensionFor: Record = { + 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; +} \ No newline at end of file diff --git a/server/src/github.ts b/server/src/github.ts new file mode 100644 index 0000000..f1b6f97 --- /dev/null +++ b/server/src/github.ts @@ -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 ` 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; + 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 } = {}; + + 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) => { + 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 ` + // lazily on every request (token refreshed on expiry). + private installTokenStrategy(): { hook: (request: unknown, options: Record) => unknown } { + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + hook: (request: any, options: { headers?: Record; [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 { + // 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 { + return this.getInstallationToken(); + } + + async getPr(): Promise { + 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 { + await this.ensureInstallationToken(); + const pr = await this.getPr(); + return (full ? pr.body : pr.body) || ""; + } + + async getTitle(): Promise { + const pr = await this.getPr(); + return pr.title; + } + + async getPrBranch(): Promise { + await this.ensureInstallationToken(); + const pr = await this.getPr(); + return pr.head.ref; + } + + async getCommits(): Promise { + 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 { + 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> { + 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; + } + + async getRepoFileContent(path: string, ref: string): Promise { + 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 { + 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 { + 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 { + 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 { + const [owner, repo] = this.repo.split("/"); + await this.retry(() => + this.octokit.issues.updateComment({ owner, repo, comment_id: commentId, body }), + ); + } + + async deleteComment(commentId: number): Promise { + const [owner, repo] = this.repo.split("/"); + await this.retry(() => + this.octokit.issues.deleteComment({ owner, repo, comment_id: commentId }), + ); + } + + async listIssueComments(): Promise { + 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 { + 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 { + 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 { + 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 { + const pr = await this.getPr(); + return pr.head.sha; + } + + async removeInitialComment(initialBodyContains: string): Promise { + 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(fn: () => Promise): Promise { + 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")}`; +} \ No newline at end of file diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..8f9d4c2 --- /dev/null +++ b/server/src/index.ts @@ -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)["score"] + ? ` — score ${(result.data["review"] as Record)["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 { + 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) { + 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"; +} \ No newline at end of file diff --git a/server/src/llm.ts b/server/src/llm.ts new file mode 100644 index 0000000..d218dae --- /dev/null +++ b/server/src/llm.ts @@ -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 { + const { model, system, user, temperature, cfg } = opts; + const messages: Record[] = []; + if (system) messages.push({ role: "system", content: system }); + messages.push({ role: "user", content: user }); + + const body: Record = { + 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); + } +} \ No newline at end of file diff --git a/server/src/markdown.ts b/server/src/markdown.ts new file mode 100644 index 0000000..265c21f --- /dev/null +++ b/server/src/markdown.ts @@ -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 = { + "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; +} + +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 += "\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 += `\n`; + else md += `### ${emoji} ${k}: ${v}\n\n`; + } else if (keyNice.toLowerCase().includes("relevant tests")) { + const v = String(value).trim().toLowerCase(); + if (gfmSupported) { + md += `\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; + const best = expandMinuteSuffix(String(obj["best_case"] ?? "")); + const avg = expandMinuteSuffix(String(obj["average_case"] ?? "")); + const worst = expandMinuteSuffix(String(obj["worst_case"] ?? "")); + if (gfmSupported) { + md += `\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 += `\n`; + else md += `### ${emoji} No security concerns identified\n\n`; + } else { + const v = emphasizeHeader(String(value).trim()); + if (gfmSupported) md += `\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 += `\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 += `\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; + 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 += `\n`; + } else { + md += `### ${emoji} Key issues to review\n\n${v}\n\n`; + } + } else if (keyNice === "Score") { + if (gfmSupported) md += `\n`; + else md += `### ${emoji} Score: ${String(value)}\n\n`; + } else if (keyNice.toLowerCase().includes("insights from user's answers")) { + if (gfmSupported) md += `\n`; + else md += `### ${emoji} Insights from user's answers\n\n${String(value)}\n\n`; + } else { + // generic row + const label = keyNice; + if (gfmSupported) md += `\n`; + else md += `### ${emoji} ${label}: ${String(value)}\n\n`; + } + } + + if (gfmSupported) md += "
${emoji} ${k}: ${v}
${emoji} ${isValueNo(v) ? "No relevant tests" : "PR contains tests"}
${emoji} Contribution time estimate (best, average, worst case): ${best} | ${avg} | ${worst}
${emoji} No security concerns identified
${emoji} Security concerns

\n\n${v}
${emoji} Todo sections

\n\n${String(value)}\n
${emoji} Can be split

\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 += `
${emoji} Key issues to review

\n\n${v}\n
${emoji} Score: ${String(value)}
${emoji} Insights from user's answers

\n\n${String(value)}\n
${emoji} ${label}: ${String(value)}
\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; + 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 += `${emoji} Ticket compliance check

\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 += `\n`; + } + } + return out; +} \ No newline at end of file diff --git a/server/src/prompts.ts b/server/src/prompts.ts new file mode 100644 index 0000000..5e59f50 --- /dev/null +++ b/server/src/prompts.ts @@ -0,0 +1,230 @@ +// Review prompt templates — verbatim port of pr_agent +// settings/pr_reviewer_prompts.toml [pr_review_prompt] system/user. +// These are nunjucks templates (jinja2-compatible constructs). + +export const REVIEW_SYSTEM_TEMPLATE = `You are PR-Reviewer, a language model designed to review a Git Pull Request (PR). +Your task is to provide constructive and concise feedback for the PR. +The review should focus on new code added in the PR code diff (lines starting with '+'), and only on issues introduced by this PR. + + +The format we will use to present the PR code diff: +====== +## File: 'src/file1.py' +{%- if is_ai_metadata %} +### AI-generated changes summary: +* ... +* ... +{%- endif %} + + +@@ ... @@ def func1(): +__new hunk__ +11 unchanged code line0 +12 unchanged code line1 +13 +new code line2 added +14 unchanged code line3 +__old hunk__ + unchanged code line0 + unchanged code line1 +-old code line2 removed + unchanged code line3 + +@@ ... @@ def func2(): +__new hunk__ + unchanged code line4 ++new code line5 added + unchanged code line6 + +## File: 'src/file2.py' +... +====== + +- In the format above, the diff is organized into separate '__new hunk__' and '__old hunk__' sections for each code chunk. '__new hunk__' contains the updated code, while '__old hunk__' shows the removed code. If no code was removed in a specific chunk, the __old hunk__ section will be omitted. +- We also added line numbers for the '__new hunk__' code, to help you refer to the code lines in your suggestions. These line numbers are not part of the actual code, and should only be used for reference. +- Code lines are prefixed with symbols ('+', '-', ' '). The '+' symbol indicates new code added in the PR, the '-' symbol indicates code removed in the PR, and the ' ' symbol indicates unchanged code. +{%- if is_ai_metadata %} +- If available, an AI-generated summary will appear and provide a high-level overview of the file changes. Note that this summary may not be fully accurate or complete. +{%- endif %} +- When quoting variables, names or file paths from the code, use backticks (\`) instead of single quote ('). +- Note that you only see changed code segments (diff hunks in a PR), not the entire codebase. Avoid suggestions that might duplicate existing functionality or questioning code elements (like variables declarations or import statements) that may be defined elsewhere in the codebase. +- Also note that if the code ends at an opening brace or statement that begins a new scope (like 'if', 'for', 'try'), don't treat it as incomplete. Instead, acknowledge the visible scope boundary and analyze only the code shown. + +Determining what to flag: +- For clear bugs and security issues, be thorough. Do not skip a genuine problem just because the trigger scenario is narrow. +- For lower-severity concerns, be certain before flagging. If you cannot confidently explain why something is a problem with a concrete scenario, do not flag it. +- Each issue must be discrete and actionable, not a vague concern about the codebase in general. +- Do not speculate that a change might break other code unless you can identify the specific affected code path from the diff context. +- Do not flag intentional design choices or stylistic preferences unless they introduce a clear defect. +- When confidence is limited but the potential impact is high (e.g., data loss, security), report it with an explicit note on what remains uncertain. Otherwise, prefer not reporting over guessing. + +Constructing comments: +- Be direct about why something is a problem and the realistic scenario where it manifests. +- Communicate severity accurately. Do not overstate impact. If an issue only arises under specific inputs or environments, say so upfront. +- Keep each issue description concise. Write so the reader grasps the point immediately without close reading. +- Use a matter-of-fact, helpful tone. Avoid accusatory language, excessive praise, or filler phrases like 'Great job', 'Thanks for'. + +{%- if skills_context %} + + +Organizational standards and review skills (apply the ones relevant to this PR): +====== +{{ skills_context }} +====== +{%- endif %} + +{%- if extra_instructions %} + + +Extra instructions from the user: +====== +{{ extra_instructions }} +====== +{% endif %} + +{%- if repo_context %} + + +Repository context: +====== +{{ repo_context }} +====== +{% endif %} + + +The output must be a YAML object equivalent to type $PRReview, according to the following Pydantic definitions: +===== +{%- if require_can_be_split_review %} +class SubPR(BaseModel): + relevant_files: List[str] = Field(description="The relevant files of the sub-PR") + title: str = Field(description="Short and concise title for an independent and meaningful sub-PR, composed only from the relevant files") +{%- endif %} + +class KeyIssuesComponentLink(BaseModel): + relevant_file: str = Field(description="The full file path of the relevant file") + issue_header: str = Field(description="One or two word title for the issue. For example: 'Possible Bug', etc.") + issue_content: str = Field(description="A short and concise description of the issue, why it matters, and the specific scenario or input that triggers it. Do not mention line numbers in this field.") + start_line: int = Field(description="The start line that corresponds to this issue in the relevant file") + end_line: int = Field(description="The end line that corresponds to this issue in the relevant file") + +{%- if require_todo_scan %} +class TodoSection(BaseModel): + relevant_file: str = Field(description="The full path of the file containing the TODO comment") + line_number: int = Field(description="The line number where the TODO comment starts") + content: str = Field(description="The content of the TODO comment. Only include actual TODO comments within code comments (e.g., comments starting with '#', '//', '/*', '