Gatekeep evals / eval-floor (push) Successful in 8s
Add .github/workflows/gatekeep-pr.yml with uses: ./ and OPENROUTER_API_KEY from secrets. Forge-agnostic API helper for Gitea.
294 lines
9.9 KiB
JavaScript
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);
|
|
});
|