Files

427 lines
16 KiB
JavaScript
Raw Permalink Normal View History

2026-10-26 20:40:51 +00:00
#!/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, …).
2026-10-26 20:40:51 +00:00
*/
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");
2026-10-26 20:40:51 +00:00
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: "≥",
};
2026-10-26 20:40:51 +00:00
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;
2026-10-26 20:40:51 +00:00
}
function loc(f) {
return f.location || (f.path ? `${f.path}${f.line ? `:${f.line}` : ""}` : T.noLoc);
2026-10-26 20:40:51 +00:00
}
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 : "";
2026-10-26 20:40:51 +00:00
const tagNote = f.tag && f.tag !== "Important" ? ` · ${f.tag}` : "";
const why = f.why_it_matters || T.whyFallback;
2026-10-26 20:40:51 +00:00
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}` +
2026-10-26 20:40:51 +00:00
detailBit +
`\n- _${T.modelsLabel}:_ ${f.models.join(", ")}`
2026-10-26 20:40:51 +00:00
);
}
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;
2026-10-26 20:40:51 +00:00
const detail = (f.body || "").trim();
let out =
`### ${emoji} ${sev}${pre} — ${f.title}\n\n` +
`**${T.where}:** \`${loc(f)}\`\n\n` +
`**${T.why}:** ${why}\n`;
2026-10-26 20:40:51 +00:00
if (detail && detail !== why) out += `\n${detail}\n`;
out += `\n---\n` + T.inlineFooter(f.models.join(", "));
2026-10-26 20:40:51 +00:00
return out;
}
const sortedAgreed = [...(merged.agreed_findings || [])];
const sortedDisputed = [...(merged.disputed_findings || [])];
const agreedList =
sortedAgreed.length === 0
? T.agreedEmpty
2026-10-26 20:40:51 +00:00
: sortedAgreed.map((f) => formatFindingBlock(f)).join("\n\n");
const disputedList =
sortedDisputed.length === 0
? T.disputedEmpty
2026-10-26 20:40:51 +00:00
: 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})` : "");
2026-10-26 20:40:51 +00:00
const body = `${marker}
${T.title}
2026-10-26 20:40:51 +00:00
${T.intro}
${errBanner}
2026-10-26 20:40:51 +00:00
| | |
| --- | --- |
| **${T.risk}** | \`${merged.risk_tier}\` |
| ${T.floor} | \`${merged.policy_floor}\` |
| ${T.agreement} | ${agreementBar} |
| ${T.agreedDisputed} | ${sortedAgreed.length} / ${sortedDisputed.length} |
| ${T.critHigh} | ${critCount} / ${highCount} |
2026-10-26 20:40:51 +00:00
${approvals(merged.risk_tier)}
${T.modelsHdr}
| ${T.modelCol} | ${T.tierCol} | ${T.findingsCol} |
2026-10-26 20:40:51 +00:00
| --- | --- | --- |
${modelRows}
${T.agreedHdr}
${T.agreedHelp}
2026-10-26 20:40:51 +00:00
${agreedList}
${T.disputedHdr}
${T.disputedHelp}
2026-10-26 20:40:51 +00:00
${disputedList}
${T.scanHdr}
${(scan.hits || []).length ? (scan.hits || []).map((h) => `- ${h}`).join("\n") : T.scanEmpty}
2026-10-26 20:40:51 +00:00
${T.policyHdr}
${(floor.reasons || []).map((r) => `- ${r}`).join("\n") || T.policyNone}
2026-10-26 20:40:51 +00:00
${T.humansHdr}
${(merged.human_focus || []).map((h) => `- ${h}`).join("\n") || T.humansDefault}
2026-10-26 20:40:51 +00:00
---
${T.footer}
2026-10-26 20:40:51 +00:00
`;
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,
2026-10-26 20:40:51 +00:00
comments: inline,
});
} catch (e) {
console.error("inline review failed; falling back to issue comments:", e.message);
2026-10-26 20:40:51 +00:00
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)}`,
2026-10-26 20:40:51 +00:00
});
}
}
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);
2026-10-26 20:40:51 +00:00
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);
});