feat: configurable report language (default zh-CN)
Gatekeep evals / eval-floor (push) Successful in 2s
Gatekeep evals / eval-floor (pull_request) Successful in 2s
gatekeep / security PR 风险 medium — 无未关闭安全阻断
Gatekeep / gatekeep (pull_request) Successful in 10m1s

Add providers.yaml `language` / Action input `language`; prompts and
published summary/inline honor it. Soften fail-closed messaging when
models 429; prefer cohere/nvidia in the first-3 parallel set for dogfood.
This commit is contained in:
layue13 committed 2026-10-27 08:29:48 +00:00
1 parent 93de34d0f0
commit ef13f8806a
10 files changed
+260 -71

No files matched your search

+11 -2
View File
@@ -27,25 +27,34 @@ Allowlists / Gitea-on-github.com limitations: [`docs/remote-uses.md`](docs/remot
## Providers YAML ## Providers YAML
```yaml ```yaml
language: zh-CN # report language: zh-CN | en | … (prompts + published comments)
providers: providers:
openrouter: # only include providers you configure openrouter: # only include providers you configure
key: OPENROUTER_API_KEY # free-form secret/env name — pick any key: OPENROUTER_API_KEY # free-form secret/env name — pick any
protocol: openai # openai | openai-completions | openai-responses | anthropic protocol: openai # openai | openai-completions | openai-responses | anthropic
# base_url: optional (defaults exist for openrouter / anthropic / openai) # base_url: optional (defaults exist for openrouter / anthropic / openai)
models: models:
- google/gemma-4-31b-it:free
- google/gemma-4-26b-a4b-it:free
- cohere/north-mini-code:free - cohere/north-mini-code:free
- nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free - nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free
- google/gemma-4-31b-it:free
``` ```
| Field | Meaning | | Field | Meaning |
| --- | --- | | --- | --- |
| `language` | Report language for model prose + summary/inline comments (`zh-CN`, `en`, …). Alias: `report_language`. |
| `key` | **Free-form** name of the GitHub secret / env var holding the API key. **No** required `gatekeep_` prefix. | | `key` | **Free-form** name of the GitHub secret / env var holding the API key. **No** required `gatekeep_` prefix. |
| `protocol` | `openai` \| `openai-completions` \| `openai-responses` \| `anthropic` | | `protocol` | `openai` \| `openai-completions` \| `openai-responses` \| `anthropic` |
| `models` | Names to use (validated against the provider’s model list API at runtime) | | `models` | Names to use (validated against the provider’s model list API at runtime) |
| `base_url` | Optional; known provider ids get defaults | | `base_url` | Optional; known provider ids get defaults |
### Report language
- **This repo’s defaults:** `language: zh-CN` in `defaults/providers.yaml`.
- **Override without editing YAML:** Action input `language: en` (or `zh-CN`).
- Resolution order: Action input `language` → YAML `language` / `report_language` → `en`.
- JSON field names and enums stay English; human-facing prose follows the configured language.
Mix providers in one review by listing several entries. Secrets are read from the **workflow step `env`** using each entry’s `key` name. Mix providers in one review by listing several entries. Secrets are read from the **workflow step `env`** using each entry’s `key` name.
Example suggestion only (still free-form): `key: gatekeep_openrouter` is fine if you named your secret that — not required. Example suggestion only (still free-form): `key: gatekeep_openrouter` is fine if you named your secret that — not required.
+15 -2
View File
@@ -33,26 +33,39 @@ github.com runner 若不允许非 github.com Action 源,见 `docs/remote-uses.
## 多供应商(providers.yaml) ## 多供应商(providers.yaml)
```yaml ```yaml
language: zh-CN # 报告语言:zh-CN | en | …(提示词 + 汇总/inline 评论)
providers: providers:
openrouter: openrouter:
key: OPENROUTER_API_KEY # 自由命名 — 任意 secret/环境变量名 key: OPENROUTER_API_KEY # 自由命名 — 任意 secret/环境变量名
protocol: openai # openai | openai-completions | openai-responses | anthropic protocol: openai # openai | openai-completions | openai-responses | anthropic
models: models:
- google/gemma-4-31b-it:free
- google/gemma-4-26b-a4b-it:free
- cohere/north-mini-code:free - cohere/north-mini-code:free
- nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free - nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free
- google/gemma-4-31b-it:free
``` ```
- **`language`:报告语言**(本仓库默认 `zh-CN`)。也可用 Action 输入 `language:` 覆盖。别名 `report_language`。
- **`key`:自由命名**,**不要**要求必须 `gatekeep_` 前缀(你也可以叫 `gatekeep_openrouter`,只是举例)。 - **`key`:自由命名**,**不要**要求必须 `gatekeep_` 前缀(你也可以叫 `gatekeep_openrouter`,只是举例)。
- 每个供应商只需:`key` + `protocol`(`base_url` / `models` 按需;已知 id 有默认 base_url)。 - 每个供应商只需:`key` + `protocol`(`base_url` / `models` 按需;已知 id 有默认 base_url)。
- **不内置死模型清单**;启动时拉取供应商模型列表并校验名字。 - **不内置死模型清单**;启动时拉取供应商模型列表并校验名字。
- 可混用多个供应商;只写你真正配置了的条目。 - 可混用多个供应商;只写你真正配置了的条目。
### 如何设置语言
| 方式 | 示例 |
| --- | --- |
| `providers.yaml` | `language: zh-CN` 或 `language: en` |
| Action 输入(优先) | `with: { language: en }` |
| 缺省 | 无 YAML / 无输入时回落 `en`;本仓库 defaults 已写 `zh-CN` |
JSON 键名与枚举值(如 `Critical`)保持英文;摘要、结论正文、inline 评论跟人读的文案跟 `language`。
## 输入面 ## 输入面
| 项 | 含义 | | 项 | 含义 |
| --- | --- | | --- | --- |
| `language` | 报告语言(`zh-CN` / `en` …);覆盖 YAML |
| `key` | Secret / 环境变量名(自由) | | `key` | Secret / 环境变量名(自由) |
| `protocol` | `openai` / `openai-completions` / `openai-responses` / `anthropic` | | `protocol` | `openai` / `openai-completions` / `openai-responses` / `anthropic` |
| `models` | 参与并行审查的模型名 | | `models` | 参与并行审查的模型名 |
+7
View File
@@ -21,6 +21,12 @@ inputs:
(or bare names unique in config). Empty = providers.yaml lists. (or bare names unique in config). Empty = providers.yaml lists.
required: false required: false
default: "" default: ""
language:
description: >
Report language for model prose + published summary/inline comments
(e.g. zh-CN, en). Empty = providers.yaml `language`, else en.
required: false
default: ""
skip-draft: skip-draft:
description: Skip draft PRs description: Skip draft PRs
required: false required: false
@@ -102,6 +108,7 @@ runs:
GITHUB_TOKEN: ${{ inputs.github-token }} GITHUB_TOKEN: ${{ inputs.github-token }}
CONFIG_DIR: ${{ inputs.config-dir }} CONFIG_DIR: ${{ inputs.config-dir }}
GATEKEEP_MODELS: ${{ inputs.models }} GATEKEEP_MODELS: ${{ inputs.models }}
GATEKEEP_LANGUAGE: ${{ inputs.language }}
SKIP_DRAFT: ${{ inputs.skip-draft }} SKIP_DRAFT: ${{ inputs.skip-draft }}
GATEKEEP_ROOT: ${{ github.workspace }} GATEKEEP_ROOT: ${{ github.workspace }}
GATEKEEP_ACTION: ${{ github.action_path }} GATEKEEP_ACTION: ${{ github.action_path }}
+4 -2
View File
@@ -1,6 +1,8 @@
# Example / owner consumer override — OpenRouter only. # Example / owner consumer override — OpenRouter only.
# `key` is free-form; map it in the workflow step env from secrets. # `key` is free-form; map it in the workflow step env from secrets.
language: zh-CN
agreement_min: 2 agreement_min: 2
temperature: 0 temperature: 0
max_tokens: 4096 max_tokens: 4096
@@ -10,7 +12,7 @@ providers:
key: OPENROUTER_API_KEY key: OPENROUTER_API_KEY
protocol: openai protocol: openai
models: models:
- google/gemma-4-31b-it:free
- google/gemma-4-26b-a4b-it:free
- cohere/north-mini-code:free - cohere/north-mini-code:free
- nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free - nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free
- google/gemma-4-31b-it:free
- google/gemma-4-26b-a4b-it:free
+8 -2
View File
@@ -2,6 +2,10 @@
# `key` is a FREE-FORM secret / env var name (no required gatekeep_ prefix). # `key` is a FREE-FORM secret / env var name (no required gatekeep_ prefix).
# Put that name in the workflow step env, e.g. env: { OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} } # Put that name in the workflow step env, e.g. env: { OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} }
# Report language for prompts + published summary/inline comments (BCP-47-ish: zh-CN, en, …).
# Override with Action input `language` when set.
language: zh-CN
agreement_min: 2 agreement_min: 2
temperature: 0 temperature: 0
max_tokens: 4096 max_tokens: 4096
@@ -10,8 +14,10 @@ providers:
openrouter: openrouter:
key: OPENROUTER_API_KEY key: OPENROUTER_API_KEY
protocol: openai protocol: openai
# First 3 are used in parallel. Prefer models that tend to answer on free tier;
# put rate-limit-prone free gemma after cohere/nvidia.
models: models:
- google/gemma-4-31b-it:free
- google/gemma-4-26b-a4b-it:free
- cohere/north-mini-code:free - cohere/north-mini-code:free
- nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free - nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free
- google/gemma-4-31b-it:free
- google/gemma-4-26b-a4b-it:free
+2 -1
View File
@@ -9,8 +9,9 @@ The Action repo itself runs Gatekeep on its own pull requests.
| Secret | `OPENROUTER_API_KEY` → step `env` | | Secret | `OPENROUTER_API_KEY` → step `env` |
| Trigger | `pull_request` (non-draft) + `workflow_dispatch` | | Trigger | `pull_request` (non-draft) + `workflow_dispatch` |
| Proven | [PR #2](https://git.potatolab.uk/layue13/gatekeep/pulls/2) got `<!-- gatekeep-review -->`; run 15833 **success** | | Proven | [PR #2](https://git.potatolab.uk/layue13/gatekeep/pulls/2) got `<!-- gatekeep-review -->`; run 15833 **success** |
| Report language | `defaults/providers.yaml` → `language: zh-CN` (override with Action `language:`) |
Gitea notes: no `actions/setup-node` mirror — Action requires runner Node ≥18. PyYAML installs via workspace venv (PEP 668). Free OpenRouter models may 429; review still posts (fail-closed tier). Gitea notes: no `actions/setup-node` mirror — Action requires runner Node ≥18. PyYAML installs via workspace venv (PEP 668). Free OpenRouter models may 429; summary softens that messaging and prefers cohere/nvidia in the first-3 set.
## How to see a review ## How to see a review
+11 -1
View File
@@ -17,6 +17,7 @@ const dir = args.dir;
const floor = args.floor || "low"; const floor = args.floor || "low";
const agreementMin = Number(args["agreement-min"] || 2); const agreementMin = Number(args["agreement-min"] || 2);
const scanHardFail = args["scan-hard-fail"] === "true"; const scanHardFail = args["scan-hard-fail"] === "true";
const language = (args.language || process.env.GATEKEEP_LANGUAGE || "en").trim() || "en";
const outPath = args.out; const outPath = args.out;
const tierRank = { low: 0, medium: 1, high: 2 }; const tierRank = { low: 0, medium: 1, high: 2 };
@@ -177,8 +178,12 @@ const bySeverity = (a, b) => (sevRank[a.severity] ?? 9) - (sevRank[b.severity] ?
agreed.sort(bySeverity); agreed.sort(bySeverity);
disputed.sort(bySeverity); disputed.sort(bySeverity);
// Prefer successful model tiers; only if every call failed, fail-closed high.
const okModels = models.filter((m) => !m.error);
const tierSources = okModels.length ? okModels : models;
let risk_tier = floor; let risk_tier = floor;
for (const m of models) risk_tier = maxTier(risk_tier, m.risk_tier); for (const m of tierSources) risk_tier = maxTier(risk_tier, m.risk_tier);
if (!okModels.length) risk_tier = maxTier(risk_tier, "high");
for (const f of agreed) { for (const f of agreed) {
if (f.kind === "security" || f.severity === "Critical") risk_tier = maxTier(risk_tier, "high"); if (f.kind === "security" || f.severity === "Critical") risk_tier = maxTier(risk_tier, "high");
else if (f.severity === "High" || f.tag === "Important") risk_tier = maxTier(risk_tier, "medium"); else if (f.severity === "High" || f.tag === "Important") risk_tier = maxTier(risk_tier, "medium");
@@ -188,10 +193,15 @@ if (scanHardFail) risk_tier = maxTier(risk_tier, "high");
const openSecurityBlocking = const openSecurityBlocking =
scanHardFail || agreed.some((f) => (f.kind === "security" || f.severity === "Critical") && f.blocking); scanHardFail || agreed.some((f) => (f.kind === "security" || f.severity === "Critical") && f.blocking);
const modelErrors = models.filter((m) => m.error).length;
const result = { const result = {
risk_tier, risk_tier,
policy_floor: floor, policy_floor: floor,
agreement_min: agreementMin, agreement_min: agreementMin,
language,
model_errors: modelErrors,
models_ok: okModels.length,
models: models.map((m) => ({ models: models.map((m) => ({
id: m.model, id: m.model,
risk_tier: m.risk_tier, risk_tier: m.risk_tier,
+172 -53
View File
@@ -2,6 +2,7 @@
/** /**
* Publish Gatekeep summary + agreed inline comments. * Publish Gatekeep summary + agreed inline comments.
* Forge-agnostic (GitHub + Gitea) via fetch — no gh CLI required. * 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"; import { readFileSync, writeFileSync } from "node:fs";
@@ -24,8 +25,123 @@ const checkName = "gatekeep / security";
const apiBase = (process.env.GITHUB_API_URL || "https://api.github.com").replace(/\/$/, ""); const apiBase = (process.env.GITHUB_API_URL || "https://api.github.com").replace(/\/$/, "");
const gitea = !apiBase.includes("api.github.com"); 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 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) =>
`> **说明:** ${n} 个模型调用失败(常见于免费档限流 429)。风险档按**失败关闭**规则取高,**不等于**代码一定有问题。成功响应:${ok} 个。`,
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.",
errNote: (n, ok) =>
`> **Note:** ${n} model call(s) failed (often free-tier 429). Risk tier is **fail-closed** high when calls fail — that does **not** mean the code is necessarily bad. Successful responses: ${ok}.`,
statusBlock: "Open security / Critical findings — merge blocked",
statusOk: (tier) => `PR risk ${tier} — no open security blockers`,
modelsAgree: "≥",
};
async function forgeApi(method, path, body) { async function forgeApi(method, path, body) {
const p = path.startsWith("/") ? path : `/${path}`; const p = path.startsWith("/") ? path : `/${path}`;
const url = `${apiBase}${p}`; const url = `${apiBase}${p}`;
@@ -48,50 +164,46 @@ async function forgeApi(method, path, body) {
} }
function approvals(tier) { function approvals(tier) {
if (tier === "low") if (tier === "low") return T.approveLow;
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 T.approveMed;
if (tier === "medium") return "Needs **1 human** look. Bot thumbs-up don't count as that look."; return T.approveHigh;
return "Needs **2 human** approvals. Bots never fill those seats.";
} }
function loc(f) { function loc(f) {
return f.location || (f.path ? `${f.path}${f.line ? `:${f.line}` : ""}` : "(no location)"); return f.location || (f.path ? `${f.path}${f.line ? `:${f.line}` : ""}` : T.noLoc);
} }
function formatFindingBlock(f, { disputed = false } = {}) { function formatFindingBlock(f, { disputed = false } = {}) {
const sev = f.severity || "Medium"; const sev = f.severity || "Medium";
const emoji = SEV_EMOJI[sev] || "⚪"; const emoji = SEV_EMOJI[sev] || "⚪";
const pre = f.tag === "Pre-existing" ? " · pre-existing" : ""; const pre = f.tag === "Pre-existing" ? T.pre : "";
const block = f.blocking ? " · blocking" : ""; const block = f.blocking ? T.block : "";
const dispute = disputed ? " · disputed (not inlined)" : ""; const dispute = disputed ? T.dispute : "";
const tagNote = f.tag && f.tag !== "Important" ? ` · ${f.tag}` : ""; const tagNote = f.tag && f.tag !== "Important" ? ` · ${f.tag}` : "";
const why = f.why_it_matters || "Impact not spelled out — please clarify before dismissing."; const why = f.why_it_matters || T.whyFallback;
const detail = (f.body || "").trim(); const detail = (f.body || "").trim();
const detailBit = detail && detail !== why ? `\n\n${detail}` : ""; const detailBit = detail && detail !== why ? `\n\n${detail}` : "";
return ( return (
`#### ${emoji} ${sev}${pre}${block}${dispute}${tagNote}: ${f.title}\n` + `#### ${emoji} ${sev}${pre}${block}${dispute}${tagNote}: ${f.title}\n` +
`- **Where:** \`${loc(f)}\`\n` + `- **${T.where}:** \`${loc(f)}\`\n` +
`- **Why it matters:** ${why}` + `- **${T.why}:** ${why}` +
detailBit + detailBit +
`\n- _Models:_ ${f.models.join(", ")}` `\n- _${T.modelsLabel}:_ ${f.models.join(", ")}`
); );
} }
function formatInline(f) { function formatInline(f) {
const sev = f.severity || "Medium"; const sev = f.severity || "Medium";
const emoji = SEV_EMOJI[sev] || "⚪"; const emoji = SEV_EMOJI[sev] || "⚪";
const pre = f.tag === "Pre-existing" ? " (pre-existing)" : ""; const pre = f.tag === "Pre-existing" ? T.preInline : "";
const why = f.why_it_matters || "Impact not spelled out — please clarify before dismissing."; const why = f.why_it_matters || T.whyFallback;
const detail = (f.body || "").trim(); const detail = (f.body || "").trim();
let out = let out =
`### ${emoji} ${sev}${pre} — ${f.title}\n\n` + `### ${emoji} ${sev}${pre} — ${f.title}\n\n` +
`**Where:** \`${loc(f)}\`\n\n` + `**${T.where}:** \`${loc(f)}\`\n\n` +
`**Why it matters:** ${why}\n`; `**${T.why}:** ${why}\n`;
if (detail && detail !== why) out += `\n${detail}\n`; if (detail && detail !== why) out += `\n${detail}\n`;
out += out += `\n---\n` + T.inlineFooter(f.models.join(", "));
`\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; return out;
} }
@@ -100,12 +212,12 @@ const sortedDisputed = [...(merged.disputed_findings || [])];
const agreedList = const agreedList =
sortedAgreed.length === 0 sortedAgreed.length === 0
? "_Nothing the models agreed on — nice, or spooky. Glance at disputed below._" ? T.agreedEmpty
: sortedAgreed.map((f) => formatFindingBlock(f)).join("\n\n"); : sortedAgreed.map((f) => formatFindingBlock(f)).join("\n\n");
const disputedList = const disputedList =
sortedDisputed.length === 0 sortedDisputed.length === 0
? "_No disagreements worth parking._" ? T.disputedEmpty
: sortedDisputed.map((f) => formatFindingBlock(f, { disputed: true })).join("\n\n"); : sortedDisputed.map((f) => formatFindingBlock(f, { disputed: true })).join("\n\n");
const modelRows = merged.models const modelRows = merged.models
@@ -118,49 +230,59 @@ const modelRows = merged.models
const critCount = sortedAgreed.filter((f) => f.severity === "Critical").length; const critCount = sortedAgreed.filter((f) => f.severity === "Critical").length;
const highCount = sortedAgreed.filter((f) => f.severity === "High").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 errBanner = errCount > 0 ? `\n${T.errNote(errCount, okCount)}\n` : "";
const agreementBar = zh
? `${T.modelsAgree}${merged.agreement_min} 个模型`
: `≥${merged.agreement_min} models`;
const body = `${marker} const body = `${marker}
## Gatekeep review ${T.title}
Hey — here's the multi-model pass. **Severity first** so you can skim Critical → Low in seconds.
${T.intro}
${errBanner}
| | | | | |
| --- | --- | | --- | --- |
| **PR risk tier** | \`${merged.risk_tier}\` | | **${T.risk}** | \`${merged.risk_tier}\` |
| Policy floor | \`${merged.policy_floor}\` | | ${T.floor} | \`${merged.policy_floor}\` |
| Agreement bar | ≥${merged.agreement_min} models | | ${T.agreement} | ${agreementBar} |
| Agreed / disputed | ${sortedAgreed.length} / ${sortedDisputed.length} | | ${T.agreedDisputed} | ${sortedAgreed.length} / ${sortedDisputed.length} |
| Critical / High (agreed) | ${critCount} / ${highCount} | | ${T.critHigh} | ${critCount} / ${highCount} |
${approvals(merged.risk_tier)} ${approvals(merged.risk_tier)}
### Models (parallel) ${T.modelsHdr}
| Model | Their PR tier | Findings | | ${T.modelCol} | ${T.tierCol} | ${T.findingsCol} |
| --- | --- | --- | | --- | --- | --- |
${modelRows} ${modelRows}
### Agreed findings ${T.agreedHdr}
Sorted by severity. Each one spells out **severity**, **where**, and **why it matters**. ${T.agreedHelp}
Fix or dismiss with \`dismissed: <reason>\` (then resolve the thread). Gatekeep has **no** chat / @-command surface.
${agreedList} ${agreedList}
### Disputed (summary only — not posted inline) ${T.disputedHdr}
One model saw it; others didn't. Useful signal, not a merge blocker by itself. ${T.disputedHelp}
${disputedList} ${disputedList}
### Deterministic security scan ${T.scanHdr}
${(scan.hits || []).length ? (scan.hits || []).map((h) => `- ${h}`).join("\n") : "_No hard-fail hits._"} ${(scan.hits || []).length ? (scan.hits || []).map((h) => `- ${h}`).join("\n") : T.scanEmpty}
### Policy floor signals ${T.policyHdr}
${(floor.reasons || []).map((r) => `- ${r}`).join("\n") || "- none"} ${(floor.reasons || []).map((r) => `- ${r}`).join("\n") || T.policyNone}
### Still on humans ${T.humansHdr}
${(merged.human_focus || []).map((h) => `- ${h}`).join("\n") || "- taste, architecture, billing/CI/auth, reading the evidence"} ${(merged.human_focus || []).map((h) => `- ${h}`).join("\n") || T.humansDefault}
--- ---
Missed something real? Open a **gatekeep-incident** issue — that's how it becomes an eval. ${T.footer}
_Inline comments only when models agree. PR tier is fail-closed. Findings lead with severity + location + why._
`; `;
async function main() { async function main() {
@@ -182,7 +304,6 @@ async function main() {
.slice(0, 30) .slice(0, 30)
.map((f) => ({ .map((f) => ({
path: f.path, path: f.path,
// Gitea uses old_position/new_position in some versions; line works on GitHub
line: f.line, line: f.line,
body: formatInline(f), body: formatInline(f),
})); }));
@@ -192,11 +313,11 @@ async function main() {
await forgeApi("POST", `/repos/${owner}/${name}/pulls/${pr.number}/reviews`, { await forgeApi("POST", `/repos/${owner}/${name}/pulls/${pr.number}/reviews`, {
commit_id: pr.head.sha, commit_id: pr.head.sha,
event: "COMMENT", event: "COMMENT",
body: "Gatekeep agreed findings (severity · where · why). Fix or dismiss — no @-commands.", body: T.reviewBody,
comments: inline, comments: inline,
}); });
} catch (e) { } catch (e) {
console.error("Inline review failed; falling back to issue comments:", e.message); console.error("inline review failed; falling back to issue comments:", e.message);
for (const f of inline) { for (const f of inline) {
await forgeApi("POST", `/repos/${owner}/${name}/issues/${pr.number}/comments`, { await forgeApi("POST", `/repos/${owner}/${name}/issues/${pr.number}/comments`, {
body: f.body + `\n\n_\`${f.path}:${f.line}\`_`, body: f.body + `\n\n_\`${f.path}:${f.line}\`_`,
@@ -212,7 +333,7 @@ async function main() {
f.path f.path
) { ) {
await forgeApi("POST", `/repos/${owner}/${name}/issues/${pr.number}/comments`, { await forgeApi("POST", `/repos/${owner}/${name}/issues/${pr.number}/comments`, {
body: formatInline(f) + `\n\n_File-level (no single line) on \`${f.path}\`._`, body: formatInline(f) + `\n\n${T.fileLevel(f.path)}`,
}); });
} }
} }
@@ -231,7 +352,6 @@ async function main() {
} }
} }
try { try {
// Gitea expects { labels: ["name"] }; GitHub same for issues API
await forgeApi("POST", `/repos/${owner}/${name}/issues/${pr.number}/labels`, { await forgeApi("POST", `/repos/${owner}/${name}/issues/${pr.number}/labels`, {
labels: [want], labels: [want],
}); });
@@ -242,10 +362,9 @@ async function main() {
const conclusion = merged.open_security_blocking ? "failure" : "success"; const conclusion = merged.open_security_blocking ? "failure" : "success";
const statusState = merged.open_security_blocking ? "failure" : "success"; const statusState = merged.open_security_blocking ? "failure" : "success";
const desc = merged.open_security_blocking const desc = merged.open_security_blocking
? "Open security / Critical findings — merge blocked" ? T.statusBlock
: `PR risk ${merged.risk_tier} — no open security blockers`; : T.statusOk(merged.risk_tier);
// Prefer Checks API (GitHub); fall back to commit status (Gitea-friendly)
try { try {
await forgeApi("POST", `/repos/${owner}/${name}/check-runs`, { await forgeApi("POST", `/repos/${owner}/${name}/check-runs`, {
name: checkName, name: checkName,
+7
View File
@@ -295,10 +295,16 @@ def main() -> None:
) )
os.chmod(secrets_path, 0o600) os.chmod(secrets_path, 0o600)
# Action input GATEKEEP_LANGUAGE overrides YAML; empty → YAML; missing → en
override_lang = (os.environ.get("GATEKEEP_LANGUAGE") or "").strip()
cfg_lang = str(cfg.get("language") or cfg.get("report_language") or "").strip()
language = override_lang or cfg_lang or "en"
plan = { plan = {
"agreement_min": int(cfg.get("agreement_min", 2)), "agreement_min": int(cfg.get("agreement_min", 2)),
"temperature": cfg.get("temperature", 0), "temperature": cfg.get("temperature", 0),
"max_tokens": int(cfg.get("max_tokens", 4096)), "max_tokens": int(cfg.get("max_tokens", 4096)),
"language": language,
"models": plan_models, "models": plan_models,
"secrets_file": str(secrets_path), "secrets_file": str(secrets_path),
"providers_used": sorted(provider_meta.keys()), "providers_used": sorted(provider_meta.keys()),
@@ -311,6 +317,7 @@ def main() -> None:
"models": [f"{m['provider']}:{m['model']}" for m in plan_models], "models": [f"{m['provider']}:{m['model']}" for m in plan_models],
"keys": {m["provider"]: m["key_name"] for m in plan_models}, "keys": {m["provider"]: m["key_name"] for m in plan_models},
"agreement_min": plan["agreement_min"], "agreement_min": plan["agreement_min"],
"language": plan["language"],
} }
), ),
flush=True, flush=True,
+23 -8
View File
@@ -39,6 +39,9 @@ python3 "$ACTION/scripts/resolve-providers.py" \
"${GATEKEEP_MODELS:-}" "${GATEKEEP_MODELS:-}"
AGREEMENT_MIN=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["agreement_min"])' "$PLAN") AGREEMENT_MIN=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["agreement_min"])' "$PLAN")
# Prefer Action input / env override already applied in plan.language
export GATEKEEP_LANGUAGE
GATEKEEP_LANGUAGE=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("language") or "en")' "$PLAN")
export GATEKEEP_MAX_TOKENS export GATEKEEP_MAX_TOKENS
GATEKEEP_MAX_TOKENS=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("max_tokens",4096))' "$PLAN") GATEKEEP_MAX_TOKENS=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("max_tokens",4096))' "$PLAN")
SECRETS_FILE=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["secrets_file"])' "$PLAN") SECRETS_FILE=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["secrets_file"])' "$PLAN")
@@ -75,15 +78,25 @@ diff = (out / "diff.txt").read_text() if (out / "diff.txt").exists() else ""
discover = json.loads((out / "discover.json").read_text()) if (out / "discover.json").exists() else {} discover = json.loads((out / "discover.json").read_text()) if (out / "discover.json").exists() else {}
plan = json.loads((out / "provider-plan.json").read_text()) plan = json.loads((out / "provider-plan.json").read_text())
system = """You are one reviewer in Gatekeep, a multi-model PR review system for the repo owner's own projects. language = (plan.get("language") or os.environ.get("GATEKEEP_LANGUAGE") or "en").strip() or "en"
lang_note = (
f"LANGUAGE: Write ALL human-facing prose in {language} "
f"(summary, rationale, finding title / why_it_matters / body, human_focus). "
f"Keep JSON keys and enum tokens in English exactly as specified "
f"(risk_tier, severity Critical|High|Medium|Low, tag, kind)."
)
system = f"""You are one reviewer in Gatekeep, a multi-model PR review system for the repo owner's own projects.
Your job is useful review content that makes human triage cheap — not chat UX. Your job is useful review content that makes human triage cheap — not chat UX.
{lang_note}
Return ONLY valid JSON (no markdown fences) with: Return ONLY valid JSON (no markdown fences) with:
{ {{
"risk_tier": "low|medium|high", "risk_tier": "low|medium|high",
"summary": "short chatty overview", "summary": "short chatty overview",
"rationale": "why this PR risk tier", "rationale": "why this PR risk tier",
"findings": [{ "findings": [{{
"severity": "Critical|High|Medium|Low", "severity": "Critical|High|Medium|Low",
"path": "file path", "path": "file path",
"line": 1, "line": 1,
@@ -94,14 +107,14 @@ Return ONLY valid JSON (no markdown fences) with:
"why_it_matters": "concrete impact if ignored", "why_it_matters": "concrete impact if ignored",
"body": "optional extra detail in a friendly tone", "body": "optional extra detail in a friendly tone",
"blocking": false "blocking": false
}], }}],
"human_focus": ["string"] "human_focus": ["string"]
} }}
Finding rules (required on EVERY finding): Finding rules (required on EVERY finding):
1. severity — Critical/High/Medium/Low so humans see critical vs minor instantly. (Distinct from PR risk_tier.) 1. severity — Critical/High/Medium/Low so humans see critical vs minor instantly. (Distinct from PR risk_tier.)
2. exact location — path + line (use line_end for a range when needed). 2. exact location — path + line (use line_end for a range when needed).
3. why_it_matters — the impact/reason, not just \"please fix\". 3. why_it_matters — the impact/reason, not just "please fix".
Tone: clear and human; structured fields, not robotic checklist-speak. Tone: clear and human; structured fields, not robotic checklist-speak.
tag is secondary (Important/Nit/Pre-existing); severity dominates. tag is secondary (Important/Nit/Pre-existing); severity dominates.
Prefer real harm over style nits. Drop pure style. kind=security or severity=Critical ⇒ blocking true. Prefer real harm over style nits. Drop pure style. kind=security or severity=Critical ⇒ blocking true.
@@ -117,6 +130,7 @@ PR risk_tier rules:
user = { user = {
"policy_floor": floor, "policy_floor": floor,
"policy_floor_reasons": [r.strip() for r in reasons.split("|") if r.strip()], "policy_floor_reasons": [r.strip() for r in reasons.split("|") if r.strip()],
"language": language,
"discover": discover, "discover": discover,
"reviewers": [f"{m['provider']}:{m['model']}" for m in plan.get("models", [])], "reviewers": [f"{m['provider']}:{m['model']}" for m in plan.get("models", [])],
"pull_request": { "pull_request": {
@@ -133,8 +147,8 @@ user = {
}, },
"diff": diff, "diff": diff,
} }
json.dump({"system": system, "user": json.dumps(user, indent=2)}, open(out / "prompt.json", "w")) json.dump({"system": system, "user": json.dumps(user, indent=2), "language": language}, open(out / "prompt.json", "w"))
print(f"Prompt ready; floor={floor}; plan={len(plan.get('models',[]))} models", flush=True) print(f"Prompt ready; floor={floor}; language={language}; plan={len(plan.get('models',[]))} models", flush=True)
PY PY
# Fire models in parallel (may be mixed providers) # Fire models in parallel (may be mixed providers)
@@ -167,6 +181,7 @@ node "$ACTION/scripts/merge-findings.js" \
--floor "$FLOOR" \ --floor "$FLOOR" \
--agreement-min "$AGREEMENT_MIN" \ --agreement-min "$AGREEMENT_MIN" \
--scan-hard-fail "$SCAN_HARD" \ --scan-hard-fail "$SCAN_HARD" \
--language "$GATEKEEP_LANGUAGE" \
--out "$OUT/merged.json" | tee "$OUT/merge-meta.json" --out "$OUT/merged.json" | tee "$OUT/merge-meta.json"
set +e set +e