Files
zhu1090093659 9a13a2c9bd chore(scripts): drop the e2e wording the skin review lane left behind
The reviewer's only e2e step was the skin preview lane, so the header, the --workdir help and the worktree-root comment no longer promise e2e artifacts beside the worktrees; docs/development.md loses the same phrase. The workspace keeps its ~/remote-e2e name.
2026-09-24 09:46:46 +08:00

922 lines
40 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* pr-review - 本地批量审核远程 PR 的 CLI 工具。
*
* 针对 dsh-web(及同类仓库)的外部 PR 审核:硬性规则 + worktree 构建验证。
* 规则来源:PR 模板(.github/pull_request_template.md)、AGENTS.md、ci.yml、
* pr-contribution-rules.yml。规则与仓库文档冲突时以文档为准。
*
* 用法:
* node scripts/pr-review.mjs 94 117 # 审核指定 PR(可多个)
* node scripts/pr-review.mjs --open # 审核全部 open PR(一次多个)
* node scripts/pr-review.mjs --open --skip-build # 只做静态检查,不构建
* node scripts/pr-review.mjs 94 --json # JSON 输出
*
* 选项:
* --repo owner/repo 目标仓库(默认从 git remote 推断)
* --include-draft 包含 draft PR(默认跳过)
* --skip-build 跳过 worktree 构建验证(只做静态检查)
* --workdir <path> worktree 工作区根目录(默认 ~/remote-e2e)
* --cleanup 清理工作区全部 worktree 与遗留 refs 后退出
*
* worktree 建在 ~/remote-e2e/pr-<N>(同 head 复用,跑完保留便于排查);
* 定期用 --cleanup 或手动 rm -rf ~/remote-e2e 清理(工具启动时会自动
* prune 已失效的 worktree 记录)。
*
* --concurrency N 并行审核数(默认 2)
* --max-added N 新增行上限,超过即拒绝(默认 10000)
* --max-deleted N 删除行上限,超过即拒绝(默认 10000)
* --max-file-bytes N 新增文本文件大小上限(默认 1048576)
* --json JSON 输出
* --no-color 禁用颜色
* -h, --help 显示帮助
*
* verdict 语义:
* REJECT 命中硬性规则(规模 / 密钥 / CI 文件 / 禁止路径 / emoji / 模板必填缺失)
* FAIL 构建或门禁失败(等价于 CI 变红)
* WARN 无硬性问题但有警告(提交信息不规范、PR 冲突、lockfile 变更等)
* PASS 静态检查与构建门禁全部通过
* SKIP draft / 已合并 / 已关闭(--include-draft 之外的 draft)
* ERROR 无法获取 PR 信息
*
* 退出码:存在 REJECT / FAIL / ERROR 时为 1,否则为 0。
*
* 硬性限制说明(本工具核心诉求):
* 外部 PR 新增或删除超过 10000 行直接拒绝,不做构建验证。AI 时代常见
* 把 pnpm 缓存(node_modules / .pnpm 等)整体提交的行为,此类变更破坏性
* 太大,由规模阈值与禁止路径规则双重拦截。
*/
import { spawnSync } from "node:child_process"
import { existsSync, mkdirSync, rmSync } from "node:fs"
import { homedir } from "node:os"
import { dirname, join, resolve } from "node:path"
import { fileURLToPath } from "node:url"
export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), `..`)
// ---------------------------------------------------------------- 常量与规则
export const DEFAULT_MAX_ADDED = 10000
export const DEFAULT_MAX_DELETED = 10000
export const DEFAULT_MAX_FILE_BYTES = 1024 * 1024
export const DEFAULT_CONCURRENCY = 2
/** worktree 工作区根目录(定期用 --cleanup 清理)。 */
export const DEFAULT_WORKTREE_ROOT = join(homedir(), `remote-e2e`)
/** 与 ci.yml 的 emoji 检查完全一致的码点范围(U+1F000-1FAFF / 2600-27BF / 2B00-2BFF / 区域指示符 / FE0F / ZWJ)。 */
export const EMOJI_RE = /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{1F1E6}-\u{1F1FF}\uFE0F\u200D]/u
/** exec 循环用(带 g,lastIndex 自动推进)。 */
export const EMOJI_GLOBAL_RE = new RegExp(EMOJI_RE.source, 'gu')
/** Conventional Commits(仓库允许的 type 集合)。 */
export const CONVENTIONAL_COMMIT_RE = /^(feat|fix|chore|docs|test|refactor|perf)(\([^)]+\))?!?: .+/
/** 已知纯文本模型(不支持图像输入):视觉修复类 PR 禁用。 */
export const TEXT_ONLY_MODEL_RES = [
/^deepseek$/i,
/deepseek[-_ ]?(chat|reasoner|r1|v3|v2|v1)/i,
/gpt[-_ ]?3(\.5)?([-_ ]turbo)?/i,
/llama[-_ ]?(2|3)/i,
/glm[-_ ]?(3|4)/i,
/moonshot|kimi/i,
/doubao|\u8c46\u5305/i,
/ernie|\u6587\u5fc3/i,
/mistral/i,
]
/** 新增二进制文件白名单(对齐 ci.yml emoji 检查的 skip_suffixes + 常见文档)。 */
export const ALLOWED_BINARY_EXT = new Set([
`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`, `.ico`,
`.woff`, `.woff2`, `.ttf`, `.otf`, `.eot`,
`.pdf`, `.zip`, `.gz`,
])
/** 新增文件禁止路径:依赖目录 / 缓存 / 构建中间产物 / 密钥类。 */
export const FORBIDDEN_PATH_RES = [
/(^|\/)node_modules(\/|$)/,
/(^|\/)\.pnpm(\/|$)/,
/(^|\/)\.pnpm-store(\/|$)/,
/(^|\/)pnpm-store(\/|$)/,
/(^|\/)\.yarn(\/|$)/,
/(^|\/)\.cache(\/|$)/,
/(^|\/)__pycache__(\/|$)/,
/(^|\/)\.pytest_cache(\/|$)/,
/(^|\/)coverage(\/|$)/,
/(^|\/)\.turbo(\/|$)/,
/(^|\/)\.next(\/|$)/,
/(^|\/)\.nuxt(\/|$)/,
/\.tsbuildinfo$/,
/\.pyc$/,
/\.DS_Store$/,
/(^|\/)\.env(\.|$)/,
/\.pem$/, /\.key$/, /\.p12$/, /\.pfx$/, /\.jks$/,
/(^|\/)\.npmrc$/,
/(^|\/)id_rsa$/, /(^|\/)id_ed25519$/, /(^|\/)id_ecdsa$/,
/(^|\/)secrets(\/|$)/,
/(^|\/)credentials(\/|$)/,
]
/** 密钥模式:命中新增行即拒绝(测试目录内命中降级为警告,避免 fixture 误拒)。 */
export const SECRET_RES = [
{ re: /AKIA[0-9A-Z]{16}/, name: `AWS access key` },
{ re: /ghp_[A-Za-z0-9]{36}/, name: `GitHub personal access token` },
{ re: /github_pat_[A-Za-z0-9_]{22,}/, name: `GitHub fine-grained token` },
{ re: /gho_[A-Za-z0-9]{36}/, name: `GitHub OAuth token` },
{ re: /ghu_[A-Za-z0-9]{36}/, name: `GitHub user token` },
{ re: /xox[baprs]-[A-Za-z0-9-]{10,}/, name: `Slack token` },
{ re: /sk-[A-Za-z0-9]{20,}/, name: `API key (OpenAI style)` },
{ re: /AIza[0-9A-Za-z_-]{35}/, name: `Google API key` },
{ re: /-----BEGIN (RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/, name: `private key` },
]
const TEST_PATH_RE = /(^|\/)(test|tests|__tests__|fixtures?)(\/|$)|(\.test|\.spec)\.[a-z0-9]+$/i
/** CI 门禁序列(对齐 ci.yml,一致性检查在 build 前跑,避免本机路径嵌入 bundle)。 */
const BUILD_STEPS = [
[`install`, `pnpm`, [`install`, `--frozen-lockfile`, `--ignore-scripts`], 20 * 60 * 1000],
[`typecheck`, `pnpm`, [`typecheck`], 10 * 60 * 1000],
[`build`, `pnpm`, [`build`], 20 * 60 * 1000],
[`test`, `pnpm`, [`test`], 15 * 60 * 1000],
[`test:scripts`, `pnpm`, [`test:scripts`], 10 * 60 * 1000],
[`test:standards`, `pnpm`, [`test:standards`], 5 * 60 * 1000],
[`aggregate:check`, `pnpm`, [`aggregate:check`], 10 * 60 * 1000],
[`docs:check`, `pnpm`, [`docs:check`], 10 * 60 * 1000],
[`i18n:check`, `pnpm`, [`i18n:check`], 5 * 60 * 1000],
]
// ---------------------------------------------------------------- 纯函数(可测)
/** 解析 git diff --numstat 输出。二进制行(- -)不计入行数。 */
export function parseNumstat(text) {
const files = []
let totalAdded = 0
let totalDeleted = 0
for (const line of String(text).split(`\n`)) {
if (!line.trim()) continue
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.*)$/)
if (!m) continue
const binary = m[1] === `-` || m[2] === `-`
const added = binary ? 0 : Number(m[1])
const deleted = binary ? 0 : Number(m[2])
files.push({ path: m[3], added, deleted, binary })
totalAdded += added
totalDeleted += deleted
}
return { files, totalAdded, totalDeleted }
}
/** 解析 git diff --name-status 输出(A/M/D 与路径)。 */
export function parseNameStatus(text) {
const out = []
for (const line of String(text).split(`\n`)) {
if (!line.trim()) continue
const m = line.match(/^([AMD])\t(.+)$/)
if (!m) continue
out.push({ status: m[1], path: m[2] })
}
return out
}
/** 规模硬性检查:新增或删除超过上限即拒绝。 */
export function checkSize(stat, maxAdded = DEFAULT_MAX_ADDED, maxDeleted = DEFAULT_MAX_DELETED) {
const findings = []
if (stat.totalAdded > maxAdded) {
findings.push({
severity: `reject`, rule: `size`,
message: `新增 ` + stat.totalAdded.toLocaleString(`en-US`) + ` 行超过上限 ` + maxAdded.toLocaleString(`en-US`) + ` 行,破坏性变更直接拒绝`,
})
}
if (stat.totalDeleted > maxDeleted) {
findings.push({
severity: `reject`, rule: `size`,
message: `删除 ` + stat.totalDeleted.toLocaleString(`en-US`) + ` 行超过上限 ` + maxDeleted.toLocaleString(`en-US`) + ` 行,破坏性变更直接拒绝`,
})
}
return findings
}
/**
* 新增文件硬性检查:禁止路径 / 非白名单二进制 / 超大文本文件。
* addedFiles: [{path, binary}](diff-filter=A 的 numstat);sizes: {path: bytes}。
*/
export function checkForbiddenFiles(addedFiles, sizes = {}, maxFileBytes = DEFAULT_MAX_FILE_BYTES) {
const findings = []
for (const file of addedFiles) {
const { path, binary } = file
const size = sizes[path] ?? 0
const dot = path.lastIndexOf(`.`)
const ext = dot === -1 ? `` : path.slice(dot).toLowerCase()
if (FORBIDDEN_PATH_RES.some((re) => re.test(path))) {
findings.push({ severity: `reject`, rule: `forbidden-path`, message: `新增文件命中禁止路径: ` + path })
continue
}
if (binary && !ALLOWED_BINARY_EXT.has(ext)) {
findings.push({ severity: `reject`, rule: `binary`, message: `新增非白名单二进制文件: ` + path + `(` + fmtBytes(size) + `)` })
continue
}
if (!binary && size > maxFileBytes) {
findings.push({ severity: `reject`, rule: `large-file`, message: `新增文本文件过大: ` + path + `(` + fmtBytes(size) + ` > ` + fmtBytes(maxFileBytes) + `)` })
} else if (binary && size > maxFileBytes * 5) {
findings.push({ severity: `warn`, rule: `large-file`, message: `新增媒体文件偏大: ` + path + `(` + fmtBytes(size) + `)` })
}
}
return findings
}
/** 从 unified=0 的 diff 文本提取所有新增行(带文件与行号)。 */
export function addedLinesFromDiff(text) {
const out = []
let file = ``
let newLine = 0
for (const line of String(text).split(`\n`)) {
if (line.startsWith(`diff --git `)) {
const m = line.match(/diff --git a\/(.*) b\//)
file = m ? m[1] : ``
newLine = 0
} else if (line.startsWith(`@@`)) {
const m = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
newLine = m ? Number(m[1]) - 1 : 0
} else if (line.startsWith(`+`) && !line.startsWith(`+++`)) {
newLine += 1
out.push({ path: file, line: newLine, text: line.slice(1) })
} else if (!line.startsWith(`-`) && !line.startsWith(`\\`)) {
newLine += 1
}
}
return out
}
/** 密钥扫描:命中新增行即拒绝;测试目录内的命中降级为警告(防 fixture 误拒)。调用方可传入预解析的新增行避免重复解析 diff。 */
export function checkSecrets(diffText, addedLines) {
const findings = []
for (const { path, line, text } of addedLines ?? addedLinesFromDiff(diffText)) {
const isTest = TEST_PATH_RE.test(path)
for (const { re, name } of SECRET_RES) {
if (re.test(text)) {
findings.push({
severity: isTest ? `warn` : `reject`, rule: `secret`,
message: `新增行疑似密钥(` + name + `): ` + path + `:` + line,
})
break
}
}
}
return findings
}
/** emoji 扫描:与 ci.yml 相同码点范围,命中新增行即拒绝。 */
export function checkEmoji(diffText, addedLines) {
const findings = []
// matchAll 复用模块级正则(不改动其 lastIndex),不再逐行 new RegExp。
for (const { path, line, text } of addedLines ?? addedLinesFromDiff(diffText)) {
for (const m of text.matchAll(EMOJI_GLOBAL_RE)) {
findings.push({
severity: `reject`, rule: `emoji`,
message: `新增行含 emoji 字符 U+` + m[0].codePointAt(0).toString(16).toUpperCase().padStart(4, `0`) + `: ` + path + `:` + line,
})
}
}
return findings
}
/** 外部 PR 修改 CI 工作流 / 脚本:供应链提权风险,直接拒绝。 */
export function checkWorkflowChanges(changes) {
const findings = []
for (const c of changes) {
if (c.path.startsWith(`.github/workflows/`) || c.path.startsWith(`.github/scripts/`)) {
findings.push({ severity: `reject`, rule: `ci-files`, message: `外部 PR 修改 CI 文件: ` + c.path })
}
}
return findings
}
/** lockfile / 依赖清单变更:提示人工核对新增依赖。 */
export function checkLockfile(changes) {
const touched = changes.filter((c) => c.path === `pnpm-lock.yaml` || c.path === `package.json`)
return touched.map((c) => ({
severity: `info`, rule: `lockfile`,
message: `` + c.path + ` 有变更,请人工核对新增 / 升级的依赖`,
}))
}
/** 提取 PR body 中某个 ## 小节的内容(去除 HTML 注释)。 */
export function readSection(body, label) {
const escaped = label.replace(/[.*+?^${{}()|[\]\\]/g, `\\$&`)
const pattern = new RegExp(`(?:^|\\n)##\\s+` + escaped + `\\s*\\n+([\\s\\S]*?)(?=\\n##\\s+|$)`, `i`)
const match = String(body || ``).match(pattern)
return match ? match[1].replace(/<!--[\s\S]*?-->/g, ``).trim() : ``
}
function isBlank(value) {
const normalized = String(value).replace(/\s+/g, ` `).trim().toLowerCase()
return normalized === `` || normalized === `no response` || normalized === `_no response_`
}
function hasCheckedLine(value, label) {
return String(value).split(`\n`).some((line) => /^-\s*\[[xX]\]/.test(line) && line.includes(label))
}
function hasAnyCheckedBox(value) {
return /^-\s*\[[xX]\]\s+\S+/m.test(String(value))
}
function hasEvidence(value) {
return /!\[[^\]]*]\([^)]+\)/i.test(String(value))
|| /https?:\/\/\S*(?:github\.com\/user-attachments\/assets\/|github\.com\/[^)\s]+\/assets\/|githubusercontent\.com\/|[./][^)\s]+\.(?:png|jpe?g|gif|webp|mp4|mov|webm))(?:[?#]\S*)?/i.test(String(value))
}
/** 模板内既有的字段标签(readField 的合法边界;正文里的普通冒号行不算边界)。 */
const FIELD_LABELS = new Set([
`执行的命令`, `结果摘要`, `使用的 AI 模型`, `使用的编码 Agent 工具`,
])
/** 读取小节内某字段(label 行之后第一个非空非注释行,或行内冒号后的内容)。 */
function readField(section, label) {
const cleaned = String(section).replace(/<!--[\s\S]*?-->/g, ``)
const lines = cleaned.split(`\n`)
const idx = lines.findIndex((l) => l.includes(label))
if (idx === -1) return ``
// 优先取 label 行内冒号后的内容;否则收集后续内容直到下一个「已知字段标签」行。
// 与 pr-contribution-rules.yml 的 readValidationPart 语义对齐:值允许代码围栏 /
// 列表 / 多行;只有模板里既有字段标签才算边界——"- xxx:yyy" 列表行与
// "全部通过:typecheck 全绿" 这类冒号正文行都曾被误判为下一个字段,
// 导致按模板填写的 PR 被误拒。
const inline = lines[idx].split(/[::]/).slice(1).join(``).trim()
if (inline) return inline
const out = []
let inFence = false
for (const line of lines.slice(idx + 1)) {
const t = line.trim()
if (t.startsWith("```")) { inFence = !inFence; continue }
if (!inFence && !/^[-*#>`!]/.test(t)) {
const m = t.match(/^([^::]{1,24})[::]\s*(\S.*)?$/)
if (m && FIELD_LABELS.has(m[1].trim())) break
}
out.push(line)
}
return out.join(`\n`).trim()
}
/**
* PR 模板合规检查:对齐 pr-contribution-rules.yml 的规则,另补充
* 摘要非空与 AI 编码披露必填(模板标注必填,CI 未查)。
* prInfo: gh pr view 的 JSON(含 body / author.login);repoOwner: 仓库 owner。
*/
export function checkTemplate(prInfo, repoOwner) {
const body = prInfo.body || ``
const findings = []
const summary = readSection(body, `摘要(Summary)`)
const prType = readSection(body, `PR 类型(PR Type)`)
const prCategory = readSection(body, `PR 类别(PR Category)`)
const latest = readSection(body, `最新代码确认(Latest Codebase Confirmation)`)
const evidenceRules = readSection(body, `测试证据与上游同步(Test Evidence & Upstream Sync)`)
const visualRules = readSection(body, `视觉修复要求(Visual Fix Requirements)`)
const validation = readSection(body, `本地验证(Local Validation)`)
const evidence = readSection(body, `用户可见变更证据(Local Feature Evidence)`)
const packages = readSection(body, `涉及包(Affected Packages)`)
if (isBlank(summary)) {
findings.push({ severity: `reject`, rule: `template`, message: `PR 摘要(Summary)为空` })
}
if (!hasAnyCheckedBox(prType)) {
findings.push({ severity: `reject`, rule: `template`, message: `PR 类型(PR Type)未勾选任何一项` })
}
if (!hasAnyCheckedBox(prCategory)) {
findings.push({ severity: `reject`, rule: `template`, message: `PR 类别(PR Category)未勾选任何一项` })
}
if (!hasCheckedLine(latest, `我已基于最新`)) {
findings.push({ severity: `reject`, rule: `template`, message: `最新代码确认(Latest Codebase Confirmation)未勾选(须基于最新 dev 分支)` })
}
const validationCommands = readField(validation, `执行的命令`)
const validationSummary = readField(validation, `结果摘要`)
if (isBlank(validationCommands) || isBlank(validationSummary)) {
findings.push({ severity: `reject`, rule: `template`, message: `本地验证(Local Validation)的执行的命令与结果摘要未填写` })
}
// AI 编码披露(模板必填;CI 未查,本地补上)
const ai = readSection(body, `AI 编码披露(AI Coding Disclosure)`)
const fullyAI = hasCheckedLine(ai, `完全 AI 编码`)
const partialAI = hasCheckedLine(ai, `部分 AI 辅助`)
const noAI = hasCheckedLine(ai, `未使用 AI 编码辅助`)
if (!fullyAI && !partialAI && !noAI) {
findings.push({ severity: `reject`, rule: `template`, message: `AI 编码披露(AI Coding Disclosure)未勾选任何一项(模板必填)` })
} else if ((fullyAI || partialAI) && !noAI) {
const model = readField(ai, `使用的 AI 模型`)
if (isBlank(model) || /^(n\/a|无)$/i.test(model)) {
findings.push({ severity: `reject`, rule: `template`, message: `声明使用 AI 编码但未填写使用的 AI 模型` })
}
}
const isRepoOwner = prInfo.author && prInfo.author.login === repoOwner
if (!isRepoOwner) {
// 贡献者 PR 证据门槛(模板「测试证据与上游同步」必填):自测证据 +
// 同步上游最新 dev 分支后重新测试通过的证据,缺失即拒绝。
if (!hasCheckedLine(evidenceRules, `我提供了自己本地测试的证据`)) {
findings.push({ severity: `reject`, rule: `template`, message: `贡献者 PR 必须勾选「测试证据与上游同步」的自测证据项(提供自己本地测试的证据)` })
}
if (!hasCheckedLine(evidenceRules, `我已同步上游最新`)) {
findings.push({ severity: `reject`, rule: `template`, message: `贡献者 PR 必须勾选「测试证据与上游同步」的上游同步项(同步 dev 最新代码并附重测证据)` })
}
const userFacing = hasCheckedLine(prType, `面向用户的功能或行为变更`)
const visualFix = hasCheckedLine(prType, `视觉修复`)
if ((visualFix || userFacing) && !hasEvidence(evidence)) {
findings.push({ severity: `reject`, rule: `template`, message: `视觉修复 / 用户可见变更的 PR 必须附带截图或视频证据(视觉修复还需完成态或修复前后对比截图)` })
}
if (visualFix) {
if (!hasCheckedLine(visualRules, `我提供了修复完成后的截图`)) {
findings.push({ severity: `reject`, rule: `template`, message: `视觉修复 PR 必须勾选「视觉修复要求」的完成截图项(提供修复完成后的截图)` })
}
const aiUsed = fullyAI || partialAI
if (aiUsed && !hasCheckedLine(visualRules, `修复使用的 AI 模型支持图像输入`)) {
findings.push({ severity: `reject`, rule: `template`, message: `视觉修复必须使用支持图像输入的多模态 AI 模型完成(「视觉修复要求」节勾选并填写模型名)` })
}
if (aiUsed) {
const model = readField(ai, `使用的 AI 模型`)
if (isBlank(model) || TEXT_ONLY_MODEL_RES.some((re) => re.test(model))) {
findings.push({ severity: `reject`, rule: `template`, message: `视觉修复使用的 AI 模型必须是多模态模型(支持图像输入);纯文本模型(如 deepseek-chat / deepseek-reasoner / gpt-3.5)修复的视觉类 PR 不接受` })
}
}
}
}
if (packages && !hasAnyCheckedBox(packages)) {
findings.push({ severity: `warn`, rule: `template`, message: `涉及包(Affected Packages)未勾选(仅文档 / 脚本改动请说明)` })
}
return findings
}
/** 提交信息检查:Conventional Commits(warn)+ emoji(reject)。 */
export function checkCommits(commits) {
const findings = []
for (const commit of commits || []) {
const headline = (commit.messageHeadline || ``).trim()
if (!headline) continue
if (!CONVENTIONAL_COMMIT_RE.test(headline)) {
findings.push({ severity: `warn`, rule: `commit-message`, message: `提交信息不符合 Conventional Commits: ` + headline })
}
if (EMOJI_RE.test(headline)) {
findings.push({ severity: `reject`, rule: `emoji`, message: `提交信息含 emoji: ` + headline })
}
}
return findings
}
/** 汇总 verdict:reject 优先,其次构建失败,其次 warn。 */
export function finalVerdict(findings, buildResult) {
if (findings.some((f) => f.severity === `reject`)) return `REJECT`
if (buildResult && buildResult.failures.length > 0) return `FAIL`
if (findings.some((f) => f.severity === `warn`)) return `WARN`
return `PASS`
}
export function fmtBytes(bytes) {
if (!bytes) return `0 B`
const units = [`B`, `KB`, `MB`, `GB`]
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1 }
return `` + (v >= 100 ? Math.round(v) : v.toFixed(1)) + ` ` + units[i]
}
// ---------------------------------------------------------------- 执行层
function run(cmd, args, opts = {}) {
const res = spawnSync(cmd, args, {
encoding: `utf8`,
maxBuffer: 256 * 1024 * 1024,
...opts,
})
if (res.error) throw res.error
return { status: res.status, stdout: res.stdout || ``, stderr: res.stderr || `` }
}
function runOk(cmd, args, opts = {}) {
const res = run(cmd, args, opts)
if (res.status !== 0) {
const tail = (res.stderr || res.stdout || ``).trim().split(`\n`).slice(-5).join(`\n`)
throw new Error(`` + cmd + ` ` + args.join(` `) + ` 失败(exit ` + res.status + `): ` + tail)
}
return res.stdout
}
function findRepoRoot(start) {
let dir = start
for (;;) {
if (existsSync(join(dir, `.git`))) return dir
const parent = resolve(dir, `..`)
if (parent === dir) throw new Error(`未找到 git 仓库根目录`)
dir = parent
}
}
function inferRepoFromRemote(repoRoot) {
const out = runOk(`git`, [`remote`, `get-url`, `origin`], { cwd: repoRoot }).trim()
const m = out.match(/(?:github\.com[:/])([^\s/]+)\/([^\s/]+?)(?:\.git)?$/)
if (!m) throw new Error(`无法从 remote 推断仓库: ` + out)
return `` + m[1] + `/` + m[2]
}
function gh(args, opts = {}) {
return runOk(`gh`, args, opts)
}
function ghJson(args) {
return JSON.parse(gh(args))
}
/** 获取 PR 基本信息(gh pr view --json)。 */
async function fetchPrInfo(repo, number) {
return ghJson([
`pr`, `view`, String(number), `--repo`, repo, `--json`,
`number,title,url,state,isDraft,mergeable,author,baseRefName,headRefName,headRefOid,body,commits,createdAt`,
])
}
/** 列出 open PR。 */
function listOpenPrs(repo) {
return ghJson([`pr`, `list`, `--repo`, repo, `--state`, `open`, `--json`, `number,isDraft`])
}
/** 解析 CLI 参数。 */
export function parseArgs(argv) {
const opts = {
prs: [], open: false, includeDraft: false, skipBuild: false, cleanup: false,
worktreeRoot: null,
concurrency: DEFAULT_CONCURRENCY, maxAdded: DEFAULT_MAX_ADDED, maxDeleted: DEFAULT_MAX_DELETED,
maxFileBytes: DEFAULT_MAX_FILE_BYTES, json: false, color: true, repo: null, help: false,
}
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i]
const next = () => { i += 1; return argv[i] }
if (/^\d+$/.test(a)) opts.prs.push(Number(a))
else if (a === `--open`) opts.open = true
else if (a === `--include-draft`) opts.includeDraft = true
else if (a === `--skip-build`) opts.skipBuild = true
else if (a === `--cleanup`) opts.cleanup = true
else if (a === `--workdir`) opts.worktreeRoot = next()
else if (a === `--json`) opts.json = true
else if (a === `--no-color`) opts.color = false
else if (a === `-h` || a === `--help`) opts.help = true
else if (a === `--repo`) opts.repo = next()
else if (a === `--concurrency`) opts.concurrency = Number(next()) || DEFAULT_CONCURRENCY
else if (a === `--max-added`) opts.maxAdded = Number(next()) || DEFAULT_MAX_ADDED
else if (a === `--max-deleted`) opts.maxDeleted = Number(next()) || DEFAULT_MAX_DELETED
else if (a === `--max-file-bytes`) opts.maxFileBytes = Number(next()) || DEFAULT_MAX_FILE_BYTES
else throw new Error(`未知参数: ` + a + `(用 --help 查看用法)`)
}
if (!opts.help && !opts.cleanup && !opts.prs.length && !opts.open) throw new Error(`需要指定 PR 编号或 --open(--cleanup 模式除外)`)
return opts
}
/** 简单并发池。 */
async function mapLimit(items, limit, fn) {
const results = new Array(items.length)
let cursor = 0
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
for (;;) {
const idx = cursor
cursor += 1
if (idx >= items.length) return
results[idx] = await fn(items[idx], idx)
}
})
await Promise.all(workers)
return results
}
function runGit(repoRoot, args) {
return runOk(`git`, args, { cwd: repoRoot })
}
/** 在指定目录(worktree)内执行 git。 */
function runGitIn(workdir, args) {
return runOk(`git`, args, { cwd: workdir })
}
/** 收集一个 PR 的 diff 数据(numstat / name-status / 新增文件 / 全文 diff / 文件大小)。
* 规模超限时不生成全文 diff(避免超大 PR 的输出与扫描开销)。
*/
export function collectPrDiff(repoRoot, prInfo, maxAdded = DEFAULT_MAX_ADDED, maxDeleted = DEFAULT_MAX_DELETED) {
const base = `origin/` + prInfo.baseRefName
const head = `refs/pr-review/` + prInfo.number + `/head`
const range = `` + base + `...` + head
const numstat = parseNumstat(runGit(repoRoot, [`diff`, `--numstat`, range]))
const allChanges = parseNameStatus(runGit(repoRoot, [`diff`, `--name-status`, range]))
const addedStat = parseNumstat(runGit(repoRoot, [`diff`, `--numstat`, `--diff-filter=A`, range]))
const overLimit = numstat.totalAdded > maxAdded || numstat.totalDeleted > maxDeleted
let diffText = ``
if (!overLimit) diffText = runGit(repoRoot, [`diff`, `--unified=0`, range])
const sizes = {}
if (addedStat.files.length) {
// 不传路径参数(新增文件过多会超出 ARG_MAX),全量列出后按需取
const wanted = new Set(addedStat.files.map((f) => f.path))
const ls = runGit(repoRoot, [`ls-tree`, `-r`, `-l`, head])
for (const line of ls.split(`\n`)) {
if (!line.trim()) continue
const tab = line.indexOf(`\t`)
if (tab === -1) continue
const path = line.slice(tab + 1)
if (!wanted.has(path)) continue
const meta = line.slice(0, tab).split(/\s+/)
sizes[path] = Number(meta[3] || 0)
}
}
return {
stat: numstat,
addedFiles: addedStat.files,
allChanges,
diffText,
sizes,
}
}
/** 静态审核:纯数据 -> findings。规模超限直接拒绝,不做内容扫描与模板检查。 */
export function staticReview(prInfo, diff, opts, repoOwner) {
const sizeFindings = checkSize(diff.stat, opts.maxAdded, opts.maxDeleted)
if (sizeFindings.some((f) => f.severity === `reject`)) {
return [...sizeFindings, ...checkForbiddenFiles(diff.addedFiles, diff.sizes, opts.maxFileBytes)]
}
// addedLinesFromDiff 解析一次,密钥与 emoji 扫描共享。
const addedLines = addedLinesFromDiff(diff.diffText)
return [
...sizeFindings,
...checkForbiddenFiles(diff.addedFiles, diff.sizes, opts.maxFileBytes),
...checkSecrets(diff.diffText, addedLines),
...checkEmoji(diff.diffText, addedLines),
...checkWorkflowChanges(diff.allChanges),
...checkLockfile(diff.allChanges),
...checkTemplate(prInfo, repoOwner),
...checkCommits(prInfo.commits),
]
}
/** 在 ~/remote-e2e 工作区 worktree 上跑 CI 门禁序列(同 head 复用,跑完保留待定期清理)。 */
export function buildVerify(repoRoot, number, headRef, worktreeRoot) {
const workdir = join(worktreeRoot, `pr-` + number)
const results = { failures: [], logs: {}, workdir }
mkdirSync(worktreeRoot, { recursive: true })
try {
const headSha = runGit(repoRoot, [`rev-parse`, headRef]).trim()
const porcelain = runGit(repoRoot, [`worktree`, `list`, `--porcelain`])
let cur = null
let existingHead = null
for (const line of porcelain.split(`\n`)) {
if (line.startsWith(`worktree `)) cur = line.slice(9)
else if (line.startsWith(`HEAD `) && cur === workdir) existingHead = line.slice(5)
}
if (existingHead === headSha) {
results.reused = true
// 上次构建会重建 bundle(嵌入本机路径),复用前恢复干净工作树(worktree 为专用临时目录)
runGitIn(workdir, [`clean`, `-fdx`])
runGitIn(workdir, [`reset`, `--hard`, `HEAD`])
} else {
if (existingHead) runGit(repoRoot, [`worktree`, `remove`, `--force`, workdir])
runGit(repoRoot, [`worktree`, `add`, `--detach`, workdir, headRef])
}
} catch (e) {
results.failures.push(`worktree`)
results.logs.worktree = String(e.message)
return results
}
try {
for (const [name, cmd, args, timeoutMs] of BUILD_STEPS) {
const res = run(cmd, args, { cwd: workdir, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 })
const tail = (res.stdout + `\n` + res.stderr).trim().split(`\n`).slice(-30).join(`\n`)
results.logs[name] = tail
if (res.status !== 0 || res.signal) {
results.failures.push(name)
break
}
}
} catch (e) {
results.failures.push(`runner`)
results.logs.runner = String(e.message)
}
return results
}
/** 清理工作区:移除其下全部 worktree、删除目录与遗留 refs。返回移除数。 */
export function cleanupWorktrees(repoRoot, worktreeRoot) {
const removed = []
try {
const porcelain = runGit(repoRoot, [`worktree`, `list`, `--porcelain`])
let cur = null
for (const line of porcelain.split(`\n`)) {
if (line.startsWith(`worktree `)) cur = line.slice(9)
else if (line === `` && cur) {
if (cur === worktreeRoot || cur.startsWith(worktreeRoot + `/`)) removed.push(cur)
cur = null
}
}
} catch { /* 忽略 */ }
for (const p of removed) {
try { runGit(repoRoot, [`worktree`, `remove`, `--force`, p]) } catch { /* 忽略 */ }
}
rmSync(worktreeRoot, { recursive: true, force: true })
try {
const refs = runGit(repoRoot, [`for-each-ref`, `--format=%(refname)`, `refs/pr-review/`])
for (const ref of refs.trim().split(`\n`)) {
if (ref) runGit(repoRoot, [`update-ref`, `-d`, ref])
}
} catch { /* 无遗留 ref */ }
return removed.length
}
/** 启动时清理上次残留:prune 失效 worktree,删除遗留 refs/pr-review/*。 */
function cleanupStale(repoRoot) {
try { runGit(repoRoot, [`worktree`, `prune`]) } catch { /* 忽略 */ }
try {
const refs = runGit(repoRoot, [`for-each-ref`, `--format=%(refname)`, `refs/pr-review/`])
for (const ref of refs.trim().split(`\n`)) {
if (ref) runGit(repoRoot, [`update-ref`, `-d`, ref])
}
} catch { /* 无遗留 ref */ }
}
// ---------------------------------------------------------------- CLI
const HELP = `用法: node scripts/pr-review.mjs [选项] [PR编号...]
本地批量审核远程 PR:静态硬性规则 + 工作区 worktree 构建验证(对齐 CI 门禁)。
PR编号... 审核指定 PR(可多个)
--open 审核全部 open PR(一次多个)
--repo owner/repo 目标仓库(默认从 git remote 推断)
--include-draft 包含 draft PR(默认跳过)
--skip-build 跳过 worktree 构建验证(只做静态检查)
--workdir <path> worktree 工作区根目录(默认 ~/remote-e2e)
--cleanup 清理工作区全部 worktree 后退出(定期清理用)
--concurrency N 并行审核数(默认 2)
--max-added N 新增行上限,超过即拒绝(默认 10000)
--max-deleted N 删除行上限,超过即拒绝(默认 10000)
--max-file-bytes N 新增文本文件大小上限(默认 1048576)
--json JSON 输出
--no-color 禁用颜色
-h, --help 显示帮助
verdict: REJECT(硬性规则) / FAIL(构建门禁) / WARN(有警告需人工) / PASS / SKIP / ERROR
退出码: 存在 REJECT/FAIL/ERROR 时为 1,否则 0。`
function colorize(color, code, text) {
return color ? `\x1b[` + code + `m` + text + `\x1b[0m` : text
}
const VERDICT_STYLE = {
REJECT: [`31`, `REJECT`],
FAIL: [`31`, `FAIL`],
ERROR: [`31`, `ERROR`],
WARN: [`33`, `WARN`],
PASS: [`32`, `PASS`],
SKIP: [`90`, `SKIP`],
}
/** 单个 PR 的完整审核(fetch 之后)。 */
async function reviewPr(number, prInfo, ctx) {
const { repoRoot, opts, repoOwner, worktreeRoot } = ctx
try {
if (prInfo.state !== `OPEN`) {
return { number, verdict: `SKIP`, reason: `PR 状态为 ` + prInfo.state, title: prInfo.title }
}
if (prInfo.isDraft && !opts.includeDraft) {
return { number, verdict: `SKIP`, reason: `draft PR(用 --include-draft 审核)`, title: prInfo.title }
}
const headRef = `refs/pr-review/` + number + `/head`
const diff = collectPrDiff(repoRoot, prInfo, opts.maxAdded, opts.maxDeleted)
const findings = staticReview(prInfo, diff, opts, repoOwner)
const rejects = findings.filter((f) => f.severity === `reject`)
let buildResult = null
if (!rejects.length) {
buildResult = opts.skipBuild
? { failures: [] }
: buildVerify(repoRoot, number, headRef, opts.worktreeRoot || DEFAULT_WORKTREE_ROOT)
}
const verdict = finalVerdict(findings, buildResult)
const result = {
number, title: prInfo.title, url: prInfo.url,
verdict, reason: ``,
author: prInfo.author?.login || ``,
isDraft: prInfo.isDraft, mergeable: prInfo.mergeable,
baseRefName: prInfo.baseRefName,
stats: {
files: diff.stat.files.length,
added: diff.stat.totalAdded,
deleted: diff.stat.totalDeleted,
newFiles: diff.addedFiles.length,
},
findings,
build: buildResult ? { failures: buildResult.failures, workdir: buildResult.workdir || null, skipped: opts.skipBuild, reused: buildResult.reused || false } : null,
}
if (verdict === `FAIL`) result.reason = `构建门禁失败: ` + buildResult.failures.join(`, `)
return result
} catch (e) {
return { number, verdict: `ERROR`, reason: String(e.message), title: prInfo.title || ``, findings: [] }
}
}
function formatHuman(results, opts) {
const c = (code, text) => colorize(opts.color, code, text)
const lines = []
for (const r of results) {
const [code, label] = VERDICT_STYLE[r.verdict] || [`0`, r.verdict]
lines.push(c(code, `#` + r.number + ` ` + label.padEnd(6) + ` ` + (r.title || ``) + `(` + (r.author || `?`) + `)`))
if (r.verdict === `ERROR` || r.verdict === `SKIP`) {
lines.push(` 原因: ` + r.reason)
lines.push(``)
continue
}
lines.push(` ` + r.stats.files + ` 个文件 +` + r.stats.added.toLocaleString(`en-US`) + `/-` + r.stats.deleted.toLocaleString(`en-US`) + ` 行(新增文件 ` + r.stats.newFiles + ` 个) mergeable=` + r.mergeable)
if (r.verdict === `FAIL`) lines.push(` 原因: ` + r.reason)
const ruleCounts = {}
const ruleShown = {}
for (const f of r.findings) ruleCounts[f.rule] = (ruleCounts[f.rule] || 0) + 1
for (const f of r.findings) {
const shown = ruleShown[f.rule] || 0
if (shown >= 8) continue
ruleShown[f.rule] = shown + 1
const tag = f.severity === `reject` ? `拒绝` : f.severity === `warn` ? `警告` : `提示`
const color = f.severity === `reject` ? `31` : f.severity === `warn` ? `33` : `90`
lines.push(c(color, ` [` + tag + `] [` + f.rule + `] ` + f.message))
}
for (const [rule, total] of Object.entries(ruleCounts)) {
if (total > 8) lines.push(` ... [` + rule + `] 共 ` + total + ` 条命中,仅显示前 8 条(用 --json 看全部)`)
}
if (r.build && r.build.failures.length) {
lines.push(c(`31`, ` [失败] 构建门禁: ` + r.build.failures.join(` -> `)))
} else if (r.build && !r.build.skipped && r.verdict === `PASS`) {
lines.push(c(`32`, ` [通过] worktree 构建与全部门禁通过`))
}
if (r.build && r.build.workdir && !r.build.skipped) lines.push(` worktree: ` + r.build.workdir + (r.build.reused ? `(复用)` : ``))
lines.push(``)
}
const summary = results.map((r) => {
const [code, label] = VERDICT_STYLE[r.verdict] || [`0`, r.verdict]
return `` + c(code, label.padEnd(6)) + ` #` + String(r.number).padStart(4) + ` ` + (r.title || ``).slice(0, 60)
})
lines.push(`-- 汇总 --`)
lines.push(...summary)
const counts = {}
for (const r of results) counts[r.verdict] = (counts[r.verdict] || 0) + 1
lines.push(`共 ` + results.length + ` 个 PR: ` + Object.entries(counts).map(([k, v]) => `` + k + ` ` + v).join(` `))
return lines.join(`\n`)
}
export async function main(argv = process.argv.slice(2), env = process.env) {
const opts = parseArgs(argv)
if (opts.help) {
console.log(HELP)
return 0
}
const cwd = env.PR_REVIEW_CWD || process.cwd()
const repoRoot = findRepoRoot(cwd)
const repo = opts.repo || inferRepoFromRemote(repoRoot)
const repoOwner = repo.split(`/`)[0]
const worktreeRoot = opts.worktreeRoot || DEFAULT_WORKTREE_ROOT
cleanupStale(repoRoot)
if (opts.cleanup) {
const removed = cleanupWorktrees(repoRoot, worktreeRoot)
console.log(`已清理工作区 ` + worktreeRoot + `(移除 ` + removed + ` 个 worktree)`)
return 0
}
const all = opts.prs.length
? opts.prs.map((n) => ({ number: n }))
: listOpenPrs(repo).map((p) => ({ number: p.number, isDraft: p.isDraft }))
if (!all.length) {
console.error(`没有可审核的 PR`)
return 0
}
// 1. 并行获取 PR 信息
const infos = await mapLimit(all, 8, async (p) => ({ p, info: await fetchPrInfo(repo, p.number) }))
// 2. 串行 fetch:base 分支与各 PR head(git fetch 有 ref 锁,必须串行)
const bases = [...new Set(infos.map(({ info }) => info.baseRefName))]
for (const b of bases) {
try { runGit(repoRoot, [`fetch`, `origin`, b]) } catch { /* base 可能已最新 */ }
}
for (const { p } of infos) {
try {
runGit(repoRoot, [`fetch`, `-f`, `origin`, `pull/` + p.number + `/head:refs/pr-review/` + p.number + `/head`])
} catch (e) {
console.error(`PR #` + p.number + ` head 获取失败: ` + e.message)
}
}
// 3. 并发审核
const results = await mapLimit(infos, opts.concurrency, async ({ p, info }) => reviewPr(p.number, info, { repoRoot, opts, repoOwner, worktreeRoot }))
// 4. 输出
if (opts.json) {
console.log(JSON.stringify({ repo, results }, null, 2))
} else {
console.log(formatHuman(results, opts))
}
return results.some((r) => r.verdict === `REJECT` || r.verdict === `FAIL` || r.verdict === `ERROR`) ? 1 : 0
}
// 直接执行(测试 import 时不触发)
if (process.argv[1] && import.meta.url === `file://` + resolve(process.argv[1])) {
main().then((code) => { process.exitCode = code })
.catch((e) => { console.error(e.message); process.exitCode = 1 })
}