Files
pr-agent-server/server/src/yaml.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

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