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.
2026-10-27 08:29:48 +00:00
* 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" );
2026-10-27 08:29:48 +00:00
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 : "🟢" };
2026-10-27 08:29:48 +00:00
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 不能顶替。" ,
2026-10-27 08:35:52 +00:00
errNote : ( n , ok , degAgree ) =>
`> **降级说明:** ${ n } 个模型调用失败(常见免费档限流 429;已重试/尝试备用模型)。` +
( ok === 0
? `全部失败 → 风险档**失败关闭为 high**,**不等于**代码一定有问题。`
: `已用 ${ ok } 个成功响应继续审查` +
( degAgree ? `(一致门槛临时降为 ${ degAgree } ) ` : "" ) +
`;失败关闭的 high **仅在零成功响应时**强制抬升。` ),
2026-10-27 08:29:48 +00:00
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." ,
2026-10-27 08:35:52 +00:00
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.` ),
2026-10-27 08:29:48 +00:00
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 ) {
2026-10-27 08:29:48 +00:00
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 ) {
2026-10-27 08:29:48 +00:00
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 ] || "⚪" ;
2026-10-27 08:29:48 +00:00
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 } ` : "" ;
2026-10-27 08:29:48 +00:00
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` +
2026-10-27 08:29:48 +00:00
`- ** ${ T . where } :** \` ${ loc ( f ) } \`\n` +
`- ** ${ T . why } :** ${ why } ` +
2026-10-26 20:40:51 +00:00
detailBit +
2026-10-27 08:29:48 +00:00
`\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 ] || "⚪" ;
2026-10-27 08:29:48 +00:00
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` +
2026-10-27 08:29:48 +00:00
`** ${ 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` ;
2026-10-27 08:29:48 +00:00
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
2026-10-27 08:29:48 +00:00
? T . agreedEmpty
2026-10-26 20:40:51 +00:00
: sortedAgreed . map (( f ) => formatFindingBlock ( f )). join ( "\n\n" );
const disputedList =
sortedDisputed . length === 0
2026-10-27 08:29:48 +00:00
? 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 ;
2026-10-27 08:29:48 +00:00
const errCount = Number ( merged . model_errors || 0 );
const okCount = Number (
merged . models_ok != null
? merged . models_ok
: ( merged . models || []). filter (( m ) => ! m . error ). length ,
);
2026-10-27 08:35:52 +00:00
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` : "" ;
2026-10-27 08:29:48 +00:00
const agreementBar = zh
2026-10-27 08:35:52 +00:00
? ` ${ T . modelsAgree }${ merged . agreement_min } 个模型` +
( degAgree ? `(本轮降为 ${ degAgree } ) ` : "" )
: `≥ ${ merged . agreement_min } models` + ( degAgree ? ` (this run: ${ degAgree } )` : "" );
2026-10-26 20:40:51 +00:00
2026-10-27 08:29:48 +00:00
const body = ` ${ marker }
${ T . title }
2026-10-26 20:40:51 +00:00
2026-10-27 08:29:48 +00:00
${ T . intro }
${ errBanner }
2026-10-26 20:40:51 +00:00
| | |
| --- | --- |
2026-10-27 08:29:48 +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 ) }
2026-10-27 08:29:48 +00:00
${ T . modelsHdr }
| ${ T . modelCol } | ${ T . tierCol } | ${ T . findingsCol } |
2026-10-26 20:40:51 +00:00
| --- | --- | --- |
${ modelRows }
2026-10-27 08:29:48 +00:00
${ T . agreedHdr }
${ T . agreedHelp }
2026-10-26 20:40:51 +00:00
${ agreedList }
2026-10-27 08:29:48 +00:00
${ T . disputedHdr }
${ T . disputedHelp }
2026-10-26 20:40:51 +00:00
${ disputedList }
2026-10-27 08:29:48 +00:00
${ T . scanHdr }
${ ( scan . hits || []). length ? ( scan . hits || []). map (( h ) => `- ${ h } ` ). join ( "\n" ) : T . scanEmpty }
2026-10-26 20:40:51 +00:00
2026-10-27 08:29:48 +00:00
${ T . policyHdr }
${ ( floor . reasons || []). map (( r ) => `- ${ r } ` ). join ( "\n" ) || T . policyNone }
2026-10-26 20:40:51 +00:00
2026-10-27 08:29:48 +00:00
${ T . humansHdr }
${ ( merged . human_focus || []). map (( h ) => `- ${ h } ` ). join ( "\n" ) || T . humansDefault }
2026-10-26 20:40:51 +00:00
---
2026-10-27 08:29:48 +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" ,
2026-10-27 08:29:48 +00:00
body : T . reviewBody ,
2026-10-26 20:40:51 +00:00
comments : inline ,
});
} catch ( e ) {
2026-10-27 08:29:48 +00:00
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` , {
2026-10-27 08:29:48 +00:00
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
2026-10-27 08:29:48 +00:00
? 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 );
});