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.
428 lines
16 KiB
JavaScript
428 lines
16 KiB
JavaScript
#!/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);
|
||
});
|