Files
pr-agent-server/server/test/review.test.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

236 lines
7.2 KiB
TypeScript

// Tests for diff processing, YAML parsing, token counting, markdown render.
import { describe, expect, test } from "bun:test";
import {
decoupleAndConvertToHunksWithLinesNumbers,
extendPatch,
generateFullPatch,
handlePatchDeletions,
omitDeletionHunks,
parseHunkHeader,
type FilePatchInfo,
EditType,
} from "../src/diff";
import { loadYaml } from "../src/yaml";
import { countTokens } from "../src/token";
import { convertToMarkdownV2 } from "../src/markdown";
import { renderTemplate } from "../src/render";
import { loadConfig, getModelTokenLimit } from "../src/config";
import { REVIEW_SYSTEM_TEMPLATE, REVIEW_USER_TEMPLATE } from "../src/prompts";
describe("diff processing", () => {
test("parseHunkHeader", () => {
expect(parseHunkHeader("@@ -12,4 +13,6 @@ def foo()")).toEqual({
start1: 12,
size1: 4,
start2: 13,
size2: 6,
sectionHeader: "def foo()",
});
expect(parseHunkHeader("@@ -1 +1 @@")).toEqual({
start1: 1,
size1: 1,
start2: 1,
size2: 1,
sectionHeader: "",
});
});
test("omitDeletionHunks drops delete-only hunks", () => {
const patch = [
"@@ -1,3 +1,3 @@",
" line1",
"-old",
"+new",
" line3",
"@@ -5,3 +5,1 @@",
"-a",
"-b",
"+c",
"@@ -10,2 +10,2 @@",
"-gone1",
"-gone2",
].join("\n");
const out = omitDeletionHunks(patch);
// hunks 1 (has +) and 2 (has +) kept; hunk 3 (delete-only) dropped
expect(out).toContain("-old");
expect(out).toContain("+new");
expect(out).toContain("+c");
expect(out).not.toContain("-gone1");
});
test("extendPatch adds context lines with line numbers", () => {
const original = ["a", "b", "c", "d", "e", "f"].join("\n");
const patch = "@@ -2,1 +2,1 @@\n b\n-c\n+d\n";
const extended = extendPatch(patch, original, 1, 1, "test.ts");
// extended hunk includes line before and after
expect(extended).toContain("@@ -1,3 +1,3 @@");
expect(extended).toContain(" a");
expect(extended).toContain(" c");
});
test("decoupleAndConvertToHunksWithLinesNumbers", () => {
const patch = "@@ -1,3 +1,3 @@\n line1\n-old\n+new\n line3\n";
const file: FilePatchInfo = {
filename: "src/x.py",
baseFile: "line1\nold\nline3\n",
headFile: "line1\nnew\nline3\n",
patch,
editType: EditType.MODIFIED,
numPlusLines: 1,
numMinusLines: 1,
};
const out = decoupleAndConvertToHunksWithLinesNumbers(patch, file);
expect(out).toContain("## File: 'src/x.py'");
expect(out).toContain("__new hunk__");
expect(out).toContain("__old hunk__");
// new hunk has numbered lines
expect(out).toContain("1 line1");
expect(out).toContain("2 +new");
expect(out).toContain("-old");
});
test("deleted file renders marker", () => {
const file: FilePatchInfo = {
filename: "gone.py",
baseFile: "x",
headFile: "",
patch: "",
editType: EditType.DELETED,
numPlusLines: 0,
numMinusLines: 1,
};
const out = decoupleAndConvertToHunksWithLinesNumbers("", file);
expect(out).toContain("was deleted");
});
test("generateFullPatch token budget", () => {
const cfg = {
...loadConfig(),
maxModelTokens: 200,
customModelMaxTokens: 200,
// disable soft/hard buffer so every file fits (mirrors test intent)
outputBufferSoftThreshold: 0,
outputBufferHardThreshold: 0,
};
const fileDict = [
{ filename: "a.py", patch: "aaa", tokens: 10, editType: EditType.MODIFIED },
{ filename: "b.py", patch: "bbb", tokens: 10, editType: EditType.MODIFIED },
];
const res = generateFullPatch(fileDict, 100, 5, cfg, true);
expect(res.patches.length).toBe(2);
expect(res.remainingFiles.length).toBe(0);
// with a tiny budget, one file is skipped
const res2 = generateFullPatch(fileDict, 11, 10, cfg, true);
expect(res2.patches.length).toBe(1);
expect(res2.remainingFiles.length).toBe(1);
});
});
describe("yaml", () => {
test("loadYaml parses clean review", () => {
const text = `review:
score: 89
relevant_tests: |
No
key_issues_to_review:
- relevant_file: src/x.py
issue_header: Possible Bug
issue_content: something
start_line: 12
end_line: 14
`;
const data = loadYaml(text, ["review:", "relevant_tests:"], "review", "security_concerns");
expect(data).not.toBeNull();
const review = (data as Record<string, unknown>)["review"] as Record<string, unknown>;
expect(review["score"]).toBe(89);
expect((review["key_issues_to_review"] as unknown[]).length).toBe(1);
});
test("loadYaml recovers from fenced yaml", () => {
const text = "Here is the review:\n```yaml\nreview:\n score: 42\n```\n";
const data = loadYaml(text, [], "review", "security_concerns");
expect((data as Record<string, unknown>)?.["review"]).toBeDefined();
});
});
describe("tokens", () => {
test("countTokens with o200k", () => {
const n = countTokens("Hello world");
expect(n).toBeGreaterThan(0);
expect(n).toBeLessThan(10);
});
});
describe("markdown", () => {
test("convertToMarkdownV2 renders sections", () => {
const md = convertToMarkdownV2(
{
review: {
estimated_effort_to_review: "3",
relevant_tests: "No",
security_concerns: "No",
key_issues_to_review: [],
score: 89,
},
},
true,
true,
);
expect(md).toContain("## PR Reviewer Guide 🔍");
expect(md).toContain("🔵");
expect(md).toContain("No relevant tests");
expect(md).toContain("Score");
});
});
describe("render", () => {
test("nunjucks StrictUndefined-equivalent", () => {
expect(renderTemplate("{% if require_score %}s={{score}}{% endif %}", { require_score: true, score: 89 })).toBe("s=89");
expect(() => renderTemplate("{{missing}}", {})).toThrow();
});
test("review prompts render with real vars", () => {
const vars = {
title: "feat: x",
branch: "main",
description: "desc",
language: "TypeScript",
diff: "diff",
num_pr_files: 2,
num_max_findings: 3,
require_score: true,
require_tests: true,
require_estimate_effort_to_review: true,
require_security_review: true,
require_estimate_contribution_time_cost: false,
require_can_be_split_review: false,
require_todo_scan: false,
question_str: "",
answer_str: "",
extra_instructions: "",
skills_context: "",
repo_context: "",
commit_messages_str: "1. fix",
custom_labels: "",
enable_custom_labels: false,
is_ai_metadata: false,
related_tickets: [],
duplicate_prompt_examples: false,
date: "2026-09-21",
};
const sys = renderTemplate(REVIEW_SYSTEM_TEMPLATE, vars);
expect(sys).toContain("You are PR-Reviewer");
expect(sys).toContain("key_issues_to_review");
const user = renderTemplate(REVIEW_USER_TEMPLATE, { ...vars, diff: "the diff" });
expect(user).toContain("Title: feat: x");
expect(user).toContain("the diff");
});
});
describe("config", () => {
test("model token limit", () => {
const cfg = loadConfig();
expect(getModelTokenLimit("claude-opus-5", cfg)).toBeLessThanOrEqual(128000);
});
});