#!/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 = ""; 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: ` (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: \` 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); });