Files
pr-agent-server/server/src/review.ts
T
asepharyana 055501aed1 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.
2026-09-21 12:50:10 +07:00

242 lines
8.1 KiB
TypeScript

// 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);
}