#!/usr/bin/env node /** * Publish Gatekeep summary + agreed inline comments. * Forge-agnostic (GitHub + Gitea) via fetch โ€” no gh CLI required. */ 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 SEV_EMOJI = { Critical: "๐Ÿ”ด", High: "๐ŸŸ ", Medium: "๐ŸŸก", Low: "๐ŸŸข" }; 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 "Looks low-risk โ€” no human needed once Gatekeep + security are green (auto-merge only if the kill switch is on)."; if (tier === "medium") return "Needs **1 human** look. Bot thumbs-up don't count as that look."; return "Needs **2 human** approvals. Bots never fill those seats."; } function loc(f) { return f.location || (f.path ? `${f.path}${f.line ? `:${f.line}` : ""}` : "(no location)"); } function formatFindingBlock(f, { disputed = false } = {}) { const sev = f.severity || "Medium"; const emoji = SEV_EMOJI[sev] || "โšช"; const pre = f.tag === "Pre-existing" ? " ยท pre-existing" : ""; const block = f.blocking ? " ยท blocking" : ""; const dispute = disputed ? " ยท disputed (not inlined)" : ""; const tagNote = f.tag && f.tag !== "Important" ? ` ยท ${f.tag}` : ""; const why = f.why_it_matters || "Impact not spelled out โ€” please clarify before dismissing."; const detail = (f.body || "").trim(); const detailBit = detail && detail !== why ? `\n\n${detail}` : ""; return ( `#### ${emoji} ${sev}${pre}${block}${dispute}${tagNote}: ${f.title}\n` + `- **Where:** \`${loc(f)}\`\n` + `- **Why it matters:** ${why}` + detailBit + `\n- _Models:_ ${f.models.join(", ")}` ); } function formatInline(f) { const sev = f.severity || "Medium"; const emoji = SEV_EMOJI[sev] || "โšช"; const pre = f.tag === "Pre-existing" ? " (pre-existing)" : ""; const why = f.why_it_matters || "Impact not spelled out โ€” please clarify before dismissing."; const detail = (f.body || "").trim(); let out = `### ${emoji} ${sev}${pre} โ€” ${f.title}\n\n` + `**Where:** \`${loc(f)}\`\n\n` + `**Why it matters:** ${why}\n`; if (detail && detail !== why) out += `\n${detail}\n`; out += `\n---\n` + `_Agreed by ${f.models.join(", ")}. Fix it, or reply \`dismissed: \` and resolve the thread._\n` + `_No chat commands โ€” Gatekeep doesn't take @-mentions._`; return out; } const sortedAgreed = [...(merged.agreed_findings || [])]; const sortedDisputed = [...(merged.disputed_findings || [])]; const agreedList = sortedAgreed.length === 0 ? "_Nothing the models agreed on โ€” nice, or spooky. Glance at disputed below._" : sortedAgreed.map((f) => formatFindingBlock(f)).join("\n\n"); const disputedList = sortedDisputed.length === 0 ? "_No disagreements worth parking._" : 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 body = `${marker} ## Gatekeep review Hey โ€” here's the multi-model pass. **Severity first** so you can skim Critical โ†’ Low in seconds. | | | | --- | --- | | **PR risk tier** | \`${merged.risk_tier}\` | | Policy floor | \`${merged.policy_floor}\` | | Agreement bar | โ‰ฅ${merged.agreement_min} models | | Agreed / disputed | ${sortedAgreed.length} / ${sortedDisputed.length} | | Critical / High (agreed) | ${critCount} / ${highCount} | ${approvals(merged.risk_tier)} ### Models (parallel) | Model | Their PR tier | Findings | | --- | --- | --- | ${modelRows} ### Agreed findings Sorted by severity. Each one spells out **severity**, **where**, and **why it matters**. Fix or dismiss with \`dismissed: \` (then resolve the thread). Gatekeep has **no** chat / @-command surface. ${agreedList} ### Disputed (summary only โ€” not posted inline) One model saw it; others didn't. Useful signal, not a merge blocker by itself. ${disputedList} ### Deterministic security scan ${(scan.hits || []).length ? (scan.hits || []).map((h) => `- ${h}`).join("\n") : "_No hard-fail hits._"} ### Policy floor signals ${(floor.reasons || []).map((r) => `- ${r}`).join("\n") || "- none"} ### Still on humans ${(merged.human_focus || []).map((h) => `- ${h}`).join("\n") || "- taste, architecture, billing/CI/auth, reading the evidence"} --- Missed something real? Open a **gatekeep-incident** issue โ€” that's how it becomes an eval. _Inline comments only when models agree. PR tier is fail-closed. Findings lead with severity + location + why._ `; 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, // Gitea uses old_position/new_position in some versions; line works on GitHub 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: "Gatekeep agreed findings (severity ยท where ยท why). Fix or dismiss โ€” no @-commands.", 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_File-level (no single line) on \`${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 { // Gitea expects { labels: ["name"] }; GitHub same for issues API 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 ? "Open security / Critical findings โ€” merge blocked" : `PR risk ${merged.risk_tier} โ€” no open security blockers`; // Prefer Checks API (GitHub); fall back to commit status (Gitea-friendly) 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); });