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.
164 lines
4.6 KiB
TypeScript
164 lines
4.6 KiB
TypeScript
// YAML loading with pr_agent's resilient fallback chain.
|
|
// Port of pr_agent.algo.utils.load_yaml + try_fix_yaml using the `yaml` package.
|
|
|
|
import * as YAML from "yaml";
|
|
|
|
export function loadYaml(
|
|
responseText: string,
|
|
keysFixYaml: string[] = [],
|
|
firstKey = "",
|
|
lastKey = "",
|
|
): Record<string, unknown> | null {
|
|
const original = responseText;
|
|
let text = responseText.replace(/^\n+/, "").trim();
|
|
text = text
|
|
.replace(/^yaml/, "")
|
|
.replace(/^```yaml/, "")
|
|
.replace(/\s*$/, "")
|
|
.replace(/```$/, "");
|
|
|
|
try {
|
|
const data = YAML.parse(text);
|
|
if (data && typeof data === "object") return data as Record<string, unknown>;
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return tryFixYaml(text, keysFixYaml, firstKey, lastKey, original);
|
|
}
|
|
|
|
const KEYS_YAML = [
|
|
"relevant line:",
|
|
"suggestion content:",
|
|
"relevant file:",
|
|
"existing code:",
|
|
"improved code:",
|
|
"label:",
|
|
"why:",
|
|
"suggestion_summary:",
|
|
];
|
|
|
|
export function tryFixYaml(
|
|
responseText: string,
|
|
keysFixYaml: string[] = [],
|
|
firstKey = "",
|
|
lastKey = "",
|
|
original = "",
|
|
): Record<string, unknown> | null {
|
|
let lines = responseText.split("\n");
|
|
const keysYaml = [...KEYS_YAML, ...keysFixYaml];
|
|
|
|
// fallback 1: convert 'key: value' to 'key: |-' multiline
|
|
let copy = lines.map((l) => {
|
|
for (const key of keysYaml) {
|
|
if (l.includes(key) && !l.includes("|")) {
|
|
return l.replace(key, `${key} |\n `);
|
|
}
|
|
}
|
|
return l;
|
|
});
|
|
let data = tryParse(copy.join("\n"));
|
|
if (data) return data;
|
|
|
|
// 1.5: '|\n' → '|2\n'
|
|
copy = responseText.replace(/\|\n/g, "|2\n").split("\n");
|
|
data = tryParse(copy.join("\n"));
|
|
if (data) return data;
|
|
// 1.5b: add 4-space indent to lines at depth 2 containing '}'
|
|
const copyB = copy.map((l) => {
|
|
const initSpace = l.length - l.trimStart().length;
|
|
if (initSpace === 2 && !l.includes("|2") && l.includes("}")) {
|
|
return " " + l.trimStart();
|
|
}
|
|
return l;
|
|
});
|
|
data = tryParse(copyB.join("\n"));
|
|
if (data) return data;
|
|
|
|
// fallback 2: extract yaml fenced block
|
|
const snippetPattern = /```(yaml|yml)?([\s\S]*?)```(?=\s*$|")/;
|
|
let m = snippetPattern.exec(copy.join("\n"));
|
|
if (!m) m = snippetPattern.exec(original);
|
|
if (m && m[2]) {
|
|
data = tryParse(m[2]);
|
|
if (data) return data;
|
|
}
|
|
|
|
// fallback 3: remove leading/trailing braces
|
|
let t3 = responseText
|
|
.trim()
|
|
.replace(/^\{/, "")
|
|
.replace(/\}$/, "")
|
|
.replace(/:$/, "");
|
|
data = tryParse(t3);
|
|
if (data) return data;
|
|
|
|
// fallback 4: extract from first_key to after last_key
|
|
if (firstKey && lastKey) {
|
|
let idxStart = responseText.indexOf(`\n${firstKey}:`);
|
|
if (idxStart === -1) idxStart = responseText.indexOf(`${firstKey}:`);
|
|
const idxLast = responseText.lastIndexOf(`${lastKey}:`);
|
|
let idxEnd = responseText.indexOf("\n\n", idxLast);
|
|
if (idxEnd === -1) idxEnd = responseText.length;
|
|
const t4 = responseText
|
|
.slice(idxStart, idxEnd)
|
|
.trim()
|
|
.replace(/^```yaml/, "")
|
|
.replace(/^`/, "")
|
|
.replace(/`$/, "");
|
|
if (t4) {
|
|
data = tryParse(t4);
|
|
if (data) return data;
|
|
}
|
|
}
|
|
|
|
// fallback 5: remove leading '+'
|
|
copy = lines.map((l) => (l.startsWith("+") ? " " + l.slice(1) : l));
|
|
data = tryParse(copy.join("\n"));
|
|
if (data) return data;
|
|
|
|
// fallback 6: tabs → spaces
|
|
if (responseText.includes("\t")) {
|
|
data = tryParse(responseText.replace(/\t/g, " "));
|
|
if (data) return data;
|
|
}
|
|
|
|
// fallback 7: indent sections for code blocks
|
|
const improveSections = ["existing_code:", "improved_code:", "response:", "why:"];
|
|
const describeSections = ["description:", "title:", "changes_diagram:", "pr_files:", "pr_ticket:"];
|
|
let startLine = -1;
|
|
const lines7 = responseText.split("\n").map((l, i) => {
|
|
const strip = l.trimEnd();
|
|
if (improveSections.some((k) => strip.includes(k)) || describeSections.some((k) => strip.includes(k))) {
|
|
startLine = i;
|
|
return l;
|
|
} else if (
|
|
strip.endsWith(": |") || strip.endsWith(": |-") || strip.endsWith(": |2") ||
|
|
keysYaml.some((k) => strip.endsWith(k))
|
|
) {
|
|
startLine = -1;
|
|
return l;
|
|
} else if (startLine !== -1) {
|
|
return " " + l;
|
|
}
|
|
return l;
|
|
});
|
|
const t7 = lines7.join("\n").replace(/ \|\n/g, " |2\n");
|
|
data = tryParse(t7);
|
|
if (data) return data;
|
|
|
|
// fallback 8: strip leading pipes
|
|
data = tryParse(responseText.replace(/^\|\n/, ""));
|
|
if (data) return data;
|
|
|
|
return null;
|
|
}
|
|
|
|
function tryParse(text: string): Record<string, unknown> | null {
|
|
try {
|
|
const d = YAML.parse(text);
|
|
if (d && typeof d === "object") return d as Record<string, unknown>;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return null;
|
|
} |