Files
layue13 c6c2d071fb
Gatekeep evals / eval-floor (push) Successful in 3s
Gatekeep evals / eval-floor (pull_request) Successful in 15s
Gatekeep / gatekeep (pull_request) Failing after 1m46s
fix: harden partial 429 success, fallbacks, zh errors
Retry HTTP 429 with backoff; keep models beyond first 3 as
fallbacks; degrade agreement when some models fail; force-high
risk only if zero usable responses. Clearer zh-CN errors/docs for
dogfood uses: ./ rebase + workflow_dispatch re-run.
2026-10-27 08:35:52 +00:00

428 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Publish Gatekeep summary + agreed inline comments.
* Forge-agnostic (GitHub + Gitea) via fetch — no gh CLI required.
* Report language from merged.language / GATEKEEP_LANGUAGE (zh-CN, en, …).
*/
import { readFileSync, writeFileSync } from "node:fs";
const mergedPath = process.argv[2];
const floorPath = process.argv[3];
const scanPath = process.argv[4];
const merged = JSON.parse(readFileSync(mergedPath, "utf8"));
const floor = JSON.parse(readFileSync(floorPath, "utf8"));
const scan = scanPath ? JSON.parse(readFileSync(scanPath, "utf8")) : { hits: [] };
const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
const pr = event.pull_request;
const repo = process.env.GITHUB_REPOSITORY;
const [owner, name] = repo.split("/");
const token =
process.env.GITHUB_TOKEN || process.env.GITEA_TOKEN || process.env.GH_TOKEN || "";
const marker = "<!-- gatekeep-review -->";
const checkName = "gatekeep / security";
const apiBase = (process.env.GITHUB_API_URL || "https://api.github.com").replace(/\/$/, "");
const gitea = !apiBase.includes("api.github.com");
const language = (
merged.language ||
process.env.GATEKEEP_LANGUAGE ||
"en"
)
.trim()
.toLowerCase();
const zh = language.startsWith("zh");
const SEV_EMOJI = { Critical: "🔴", High: "🟠", Medium: "🟡", Low: "🟢" };
const T = zh
? {
title: "## Gatekeep 审查",
intro: "多模型并行审查结果如下。**先看严重级别**,Critical → Low 几秒扫完。",
risk: "PR 风险档",
floor: "策略底线",
agreement: "一致门槛",
agreedDisputed: "一致 / 分歧",
critHigh: "Critical / High(一致)",
modelsHdr: "### 模型(并行)",
modelCol: "模型",
tierCol: "其 PR 档",
findingsCol: "结论数",
agreedHdr: "### 一致结论",
agreedHelp:
"按严重级别排序。每条都写清 **严重级别**、**位置**、**为什么重要**。\n修复,或回复 `dismissed: <原因>` 后 resolve 讨论串。Gatekeep **没有**聊天 / @ 命令入口。",
agreedEmpty: "_模型之间没有达成一致的结论 — 可能很好,也可能该看分歧。_",
disputedHdr: "### 分歧(仅摘要,不上 inline)",
disputedHelp: "只有部分模型看到。有参考价值,本身不挡合并。",
disputedEmpty: "_没有值得单独列出的分歧。_",
scanHdr: "### 确定性安全扫描",
scanEmpty: "_无硬失败命中。_",
policyHdr: "### 策略底线信号",
policyNone: "- 无",
humansHdr: "### 仍需人工",
humansDefault: "- 品味、架构、计费/CI/鉴权、读证据",
footer:
"漏了真问题?开 **gatekeep-incident** issue — 这样才能变成 eval。\n_仅在模型一致时发 inline。PR 档失败关闭。结论以严重级别 + 位置 + 原因为先。_",
where: "位置",
why: "为什么重要",
whyFallback: "未写清影响 — 驳回前请先补一句。",
modelsLabel: "模型",
pre: " · 既有",
block: " · 阻断",
dispute: " · 分歧(未上 inline)",
preInline: "(既有)",
inlineFooter: (models) =>
`_由 ${models} 一致认定。请修复,或回复 \`dismissed: <原因>\` 后 resolve。_\n_无聊天命令 — Gatekeep 不响应 @。_`,
reviewBody: "Gatekeep 一致结论(严重级别 · 位置 · 原因)。请修复或驳回 — 无 @ 命令。",
fileLevel: (path) => `_文件级(无线号)于 \`${path}\`。_`,
noLoc: "(无位置)",
approveLow:
"看起来风险低 — Gatekeep + 安全绿灯后可不需人工(仅当 kill switch 打开才可自动合并)。",
approveMed: "需要 **1 名人工**过目。Bot 点赞不算人工。",
approveHigh: "需要 **2 名人工**批准。Bot 不能顶替。",
errNote: (n, ok, degAgree) =>
`> **降级说明:** ${n} 个模型调用失败(常见免费档限流 429;已重试/尝试备用模型)。` +
(ok === 0
? `全部失败 → 风险档**失败关闭为 high**,**不等于**代码一定有问题。`
: `已用 ${ok} 个成功响应继续审查` +
(degAgree ? `(一致门槛临时降为 ${degAgree})` : "") +
`;失败关闭的 high **仅在零成功响应时**强制抬升。`),
statusBlock: "存在未关闭的安全 / Critical 结论 — 合并阻断",
statusOk: (tier) => `PR 风险 ${tier} — 无未关闭安全阻断`,
modelsAgree: "一致门槛 ≥",
}
: {
title: "## Gatekeep review",
intro:
"Hey — here's the multi-model pass. **Severity first** so you can skim Critical → Low in seconds.",
risk: "PR risk tier",
floor: "Policy floor",
agreement: "Agreement bar",
agreedDisputed: "Agreed / disputed",
critHigh: "Critical / High (agreed)",
modelsHdr: "### Models (parallel)",
modelCol: "Model",
tierCol: "Their PR tier",
findingsCol: "Findings",
agreedHdr: "### Agreed findings",
agreedHelp:
"Sorted by severity. Each one spells out **severity**, **where**, and **why it matters**.\nFix or dismiss with `dismissed: <reason>` (then resolve the thread). Gatekeep has **no** chat / @-command surface.",
agreedEmpty:
"_Nothing the models agreed on — nice, or spooky. Glance at disputed below._",
disputedHdr: "### Disputed (summary only — not posted inline)",
disputedHelp:
"One model saw it; others didn't. Useful signal, not a merge blocker by itself.",
disputedEmpty: "_No disagreements worth parking._",
scanHdr: "### Deterministic security scan",
scanEmpty: "_No hard-fail hits._",
policyHdr: "### Policy floor signals",
policyNone: "- none",
humansHdr: "### Still on humans",
humansDefault: "- taste, architecture, billing/CI/auth, reading the evidence",
footer:
"Missed something real? Open a **gatekeep-incident** issue — that's how it becomes an eval.\n_Inline comments only when models agree. PR tier is fail-closed. Findings lead with severity + location + why._",
where: "Where",
why: "Why it matters",
whyFallback: "Impact not spelled out — please clarify before dismissing.",
modelsLabel: "Models",
pre: " · pre-existing",
block: " · blocking",
dispute: " · disputed (not inlined)",
preInline: " (pre-existing)",
inlineFooter: (models) =>
`_Agreed by ${models}. Fix it, or reply \`dismissed: <reason>\` and resolve the thread._\n_No chat commands — Gatekeep doesn't take @-mentions._`,
reviewBody:
"Gatekeep agreed findings (severity · where · why). Fix or dismiss — no @-commands.",
fileLevel: (path) => `_File-level (no single line) on \`${path}\`._`,
noLoc: "(no location)",
approveLow:
"Looks low-risk — no human needed once Gatekeep + security are green (auto-merge only if the kill switch is on).",
approveMed: "Needs **1 human** look. Bot thumbs-up don't count as that look.",
approveHigh: "Needs **2 human** approvals. Bots never fill those seats.",
errNote: (n, ok, degAgree) =>
`> **Degraded:** ${n} model call(s) failed (often free-tier 429; retried / tried fallbacks). ` +
(ok === 0
? `Zero usable responses → risk tier **fail-closed high** (not proof the code is bad).`
: `Reviewing from ${ok} successful response(s)` +
(degAgree ? ` (agreement bar temporarily ${degAgree})` : "") +
`; fail-closed high is forced only when **zero** models succeed.`),
statusBlock: "Open security / Critical findings — merge blocked",
statusOk: (tier) => `PR risk ${tier} — no open security blockers`,
modelsAgree: "≥",
};
async function forgeApi(method, path, body) {
const p = path.startsWith("/") ? path : `/${path}`;
const url = `${apiBase}${p}`;
const headers = {
Accept: gitea ? "application/json" : "application/vnd.github+json",
"User-Agent": "gatekeep-action",
Authorization: gitea ? `token ${token}` : `Bearer ${token}`,
};
if (body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
if (!res.ok) {
throw new Error(`${method} ${p} → HTTP ${res.status}: ${text.slice(0, 400)}`);
}
return text.trim() ? JSON.parse(text) : null;
}
function approvals(tier) {
if (tier === "low") return T.approveLow;
if (tier === "medium") return T.approveMed;
return T.approveHigh;
}
function loc(f) {
return f.location || (f.path ? `${f.path}${f.line ? `:${f.line}` : ""}` : T.noLoc);
}
function formatFindingBlock(f, { disputed = false } = {}) {
const sev = f.severity || "Medium";
const emoji = SEV_EMOJI[sev] || "⚪";
const pre = f.tag === "Pre-existing" ? T.pre : "";
const block = f.blocking ? T.block : "";
const dispute = disputed ? T.dispute : "";
const tagNote = f.tag && f.tag !== "Important" ? ` · ${f.tag}` : "";
const why = f.why_it_matters || T.whyFallback;
const detail = (f.body || "").trim();
const detailBit = detail && detail !== why ? `\n\n${detail}` : "";
return (
`#### ${emoji} ${sev}${pre}${block}${dispute}${tagNote}: ${f.title}\n` +
`- **${T.where}:** \`${loc(f)}\`\n` +
`- **${T.why}:** ${why}` +
detailBit +
`\n- _${T.modelsLabel}:_ ${f.models.join(", ")}`
);
}
function formatInline(f) {
const sev = f.severity || "Medium";
const emoji = SEV_EMOJI[sev] || "⚪";
const pre = f.tag === "Pre-existing" ? T.preInline : "";
const why = f.why_it_matters || T.whyFallback;
const detail = (f.body || "").trim();
let out =
`### ${emoji} ${sev}${pre} — ${f.title}\n\n` +
`**${T.where}:** \`${loc(f)}\`\n\n` +
`**${T.why}:** ${why}\n`;
if (detail && detail !== why) out += `\n${detail}\n`;
out += `\n---\n` + T.inlineFooter(f.models.join(", "));
return out;
}
const sortedAgreed = [...(merged.agreed_findings || [])];
const sortedDisputed = [...(merged.disputed_findings || [])];
const agreedList =
sortedAgreed.length === 0
? T.agreedEmpty
: sortedAgreed.map((f) => formatFindingBlock(f)).join("\n\n");
const disputedList =
sortedDisputed.length === 0
? T.disputedEmpty
: sortedDisputed.map((f) => formatFindingBlock(f, { disputed: true })).join("\n\n");
const modelRows = merged.models
.map(
(m) =>
`| \`${m.id}\` | \`${m.risk_tier}\` | ${m.finding_count}${m.error ? ` (err: ${m.error})` : ""} |`,
)
.join("\n");
const critCount = sortedAgreed.filter((f) => f.severity === "Critical").length;
const highCount = sortedAgreed.filter((f) => f.severity === "High").length;
const errCount = Number(merged.model_errors || 0);
const okCount = Number(
merged.models_ok != null
? merged.models_ok
: (merged.models || []).filter((m) => !m.error).length,
);
const effAgree = merged.effective_agreement_min;
const degAgree =
merged.degraded && effAgree != null && effAgree < merged.agreement_min ? effAgree : null;
const errBanner =
errCount > 0 || merged.degraded ? `\n${T.errNote(errCount, okCount, degAgree)}\n` : "";
const agreementBar = zh
? `${T.modelsAgree}${merged.agreement_min} 个模型` +
(degAgree ? `(本轮降为 ${degAgree})` : "")
: `≥${merged.agreement_min} models` + (degAgree ? ` (this run: ${degAgree})` : "");
const body = `${marker}
${T.title}
${T.intro}
${errBanner}
| | |
| --- | --- |
| **${T.risk}** | \`${merged.risk_tier}\` |
| ${T.floor} | \`${merged.policy_floor}\` |
| ${T.agreement} | ${agreementBar} |
| ${T.agreedDisputed} | ${sortedAgreed.length} / ${sortedDisputed.length} |
| ${T.critHigh} | ${critCount} / ${highCount} |
${approvals(merged.risk_tier)}
${T.modelsHdr}
| ${T.modelCol} | ${T.tierCol} | ${T.findingsCol} |
| --- | --- | --- |
${modelRows}
${T.agreedHdr}
${T.agreedHelp}
${agreedList}
${T.disputedHdr}
${T.disputedHelp}
${disputedList}
${T.scanHdr}
${(scan.hits || []).length ? (scan.hits || []).map((h) => `- ${h}`).join("\n") : T.scanEmpty}
${T.policyHdr}
${(floor.reasons || []).map((r) => `- ${r}`).join("\n") || T.policyNone}
${T.humansHdr}
${(merged.human_focus || []).map((h) => `- ${h}`).join("\n") || T.humansDefault}
---
${T.footer}
`;
async function main() {
const comments =
(await forgeApi(
"GET",
`/repos/${owner}/${name}/issues/${pr.number}/comments?limit=100&per_page=100`,
)) || [];
const list = Array.isArray(comments) ? comments : [];
const existing = list.find((c) => (c.body || "").includes(marker));
if (existing) {
await forgeApi("PATCH", `/repos/${owner}/${name}/issues/comments/${existing.id}`, { body });
} else {
await forgeApi("POST", `/repos/${owner}/${name}/issues/${pr.number}/comments`, { body });
}
const inline = sortedAgreed
.filter((f) => f.path && f.line && f.severity !== "Low" && f.tag !== "Nit")
.slice(0, 30)
.map((f) => ({
path: f.path,
line: f.line,
body: formatInline(f),
}));
if (inline.length) {
try {
await forgeApi("POST", `/repos/${owner}/${name}/pulls/${pr.number}/reviews`, {
commit_id: pr.head.sha,
event: "COMMENT",
body: T.reviewBody,
comments: inline,
});
} catch (e) {
console.error("inline review failed; falling back to issue comments:", e.message);
for (const f of inline) {
await forgeApi("POST", `/repos/${owner}/${name}/issues/${pr.number}/comments`, {
body: f.body + `\n\n_\`${f.path}:${f.line}\`_`,
});
}
}
}
for (const f of sortedAgreed) {
if (
(f.severity === "Critical" || f.severity === "High" || f.tag === "Important") &&
!f.line &&
f.path
) {
await forgeApi("POST", `/repos/${owner}/${name}/issues/${pr.number}/comments`, {
body: formatInline(f) + `\n\n${T.fileLevel(f.path)}`,
});
}
}
const want = `risk:${merged.risk_tier}`;
for (const lab of ["risk:low", "risk:medium", "risk:high"]) {
if (lab !== want) {
try {
await forgeApi(
"DELETE",
`/repos/${owner}/${name}/issues/${pr.number}/labels/${encodeURIComponent(lab)}`,
);
} catch {
/* label may not exist */
}
}
}
try {
await forgeApi("POST", `/repos/${owner}/${name}/issues/${pr.number}/labels`, {
labels: [want],
});
} catch (e) {
console.error("label:", e.message);
}
const conclusion = merged.open_security_blocking ? "failure" : "success";
const statusState = merged.open_security_blocking ? "failure" : "success";
const desc = merged.open_security_blocking
? T.statusBlock
: T.statusOk(merged.risk_tier);
try {
await forgeApi("POST", `/repos/${owner}/${name}/check-runs`, {
name: checkName,
head_sha: pr.head.sha,
status: "completed",
conclusion,
output: {
title: desc,
summary: desc,
},
});
} catch {
try {
await forgeApi("POST", `/repos/${owner}/${name}/statuses/${pr.head.sha}`, {
state: statusState,
context: checkName,
description: desc.slice(0, 140),
});
} catch (e2) {
console.error("status/check publish failed:", e2.message);
}
}
if (process.env.GITHUB_OUTPUT) {
writeFileSync(
process.env.GITHUB_OUTPUT,
[
`risk_tier=${merged.risk_tier}`,
`security_blocking=${merged.open_security_blocking}`,
`agreed_count=${sortedAgreed.length}`,
].join("\n") + "\n",
{ flag: "a" },
);
}
if (merged.open_security_blocking) {
console.error("Security blocking — exiting non-zero for job red X");
process.exit(1);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});