Files
gatekeep/scripts/publish.js
T
layue13 05f8fad38a
Gatekeep evals / eval-floor (push) Successful in 8s
Dogfood: run Gatekeep on this repo's PRs
Add .github/workflows/gatekeep-pr.yml with uses: ./ and
OPENROUTER_API_KEY from secrets. Forge-agnostic API helper for Gitea.
2026-10-26 20:40:51 +00:00

294 lines
9.9 KiB
JavaScript

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