feat(server): Bun/TypeScript re-implementation of PR review engine
Reimplements the pr_agent review pipeline (previously Python package) in
Bun/TypeScript, verified end-to-end against a real GitHub App + 9router:
- src/diff.ts: git patch processing (extend_patch, line-numbered hunks,
token-budget generate_full_patch, generated/invalid file filters)
- src/review.ts: orchestrator (fetch PR/diff -> render prompts -> LLM ->
YAML parse -> markdown render -> publish), fallback models
- src/github.ts: GitHub App auth via @octokit/auth-app (JWT -> installation
token, custom authStrategy for Bun), PR/diff/languages/comments REST
- src/prompts.ts: verbatim pr_reviewer_prompts.toml port (nunjucks templates)
- src/yaml.ts: resilient load_yaml with 9 fallback fixers
- src/markdown.ts: convert_to_markdown_v2 port ('PR Reviewer Guide' comment)
- src/llm.ts: 9router OpenAI-compatible chat completions (non-stream,
temp omitted for claude-opus-5), timeout 600s
- src/token.ts: js-tiktoken o200k counting
- src/index.ts: webhook server (HMAC verify, segment analytics, /health,
/api/metrics) + cli.ts one-shot review
- e2e.ts: real-world harness (bun e2e --repo o/r --pr N [--publish])
Verified: bun test 13/13, tsc clean, E2E published a real review comment
(## PR Reviewer Guide) on asepharyana/nextjs-template#19 via GitHub App
MythEclipseBotReview + claude-opus-5 through 9router.
Python run_server.py + pr_agent remain for the live queue worker; server/
is the replacement path.
This commit is contained in:
1 parent
835552c5ca
commit
055501aed1
19 files changed
+3119
-1
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user