cc-haha/scripts/pr/check-pr.ts
程序员阿江(Relakkes) fdf5fe2653 Protect PR merges with scoped quality gates
Pull requests need a deterministic way to show changed areas, required checks, missing-test signals, and CLI-core risk before review. This adds a path-based policy gate, local impact reporting, PR triage labels/comments, and reusable check scripts so reviewers can evaluate blast radius without trusting contributor claims.

Constraint: CLI core should remain effectively frozen unless a maintainer explicitly overrides it.
Constraint: Default PR checks must be safe for external forks and avoid live model/provider calls.
Rejected: Run live provider tests on every PR | secrets, cost, network, and vendor instability would make the gate noisy and unsafe.
Rejected: Use Dosu as the merge gate | AI review is useful for risk explanation, but deterministic Actions must own blocking checks.
Confidence: high
Scope-risk: moderate
Directive: Keep real model/provider smoke tests in maintainer-controlled workflows; do not make them required for untrusted PRs.
Tested: bun run check:impact
Tested: bun run check:policy
Tested: ruby YAML parse for PR workflows
Tested: git diff --check
Tested: bun run check:native
Tested: npm run docs:build
Tested: bun run scripts/pr/run-server-tests.ts
Tested: bun run check:adapters
Tested: bun run check:desktop
Not-tested: GitHub-hosted pull_request_target label/comment execution before opening this PR
2026-05-02 11:01:07 +08:00

115 lines
3.0 KiB
TypeScript

#!/usr/bin/env bun
import { evaluateChangePolicy } from './change-policy'
async function run(cmd: string[], options: { optional?: boolean } = {}) {
console.log(`\n$ ${cmd.join(' ')}`)
const proc = Bun.spawn(cmd, {
stdout: 'inherit',
stderr: 'inherit',
})
const code = await proc.exited
if (code !== 0 && !options.optional) {
process.exit(code)
}
return code
}
async function output(cmd: string[]) {
const proc = Bun.spawn(cmd, {
stdout: 'pipe',
stderr: 'pipe',
})
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const code = await proc.exited
if (code !== 0) {
throw new Error(stderr || stdout || `Command failed: ${cmd.join(' ')}`)
}
return stdout.trim()
}
async function outputOrEmpty(cmd: string[]) {
try {
return await output(cmd)
} catch {
return ''
}
}
async function localChangedFiles() {
const staged = await outputOrEmpty(['git', 'diff', '--name-only', '--cached'])
const unstaged = await outputOrEmpty(['git', 'diff', '--name-only'])
const untracked = await outputOrEmpty(['git', 'ls-files', '--others', '--exclude-standard'])
return [...staged.split(/\r?\n/), ...unstaged.split(/\r?\n/), ...untracked.split(/\r?\n/)]
.filter(Boolean)
}
async function changedFiles() {
const explicit = process.argv.slice(2).filter((arg) => !arg.startsWith('--'))
if (explicit.length > 0) {
return explicit
}
const base = process.env.PR_BASE_REF ?? 'origin/main'
const localFiles = await localChangedFiles()
try {
const diff = await output(['git', 'diff', '--name-only', `${base}...HEAD`])
return [...new Set([...diff.split(/\r?\n/), ...localFiles].filter(Boolean))]
} catch {
try {
const diff = await output(['git', 'diff', '--name-only', 'main...HEAD'])
return [...new Set([...diff.split(/\r?\n/), ...localFiles].filter(Boolean))]
} catch {
return [...new Set(localFiles)]
}
}
}
const files = await changedFiles()
const labels = process.env.PR_LABELS?.split(',').map((label) => label.trim()).filter(Boolean) ?? []
if (process.env.ALLOW_CLI_CORE_CHANGE === '1' && !labels.includes('allow-cli-core-change')) {
labels.push('allow-cli-core-change')
}
const result = evaluateChangePolicy(files, labels)
console.log('PR local check plan')
console.log(` Files: ${files.length}`)
console.log(` Areas: ${result.areas.length ? result.areas.join(', ') : 'none'}`)
if (result.blockingReason) {
console.error(`\nBlocked: ${result.blockingReason}`)
console.error('Set ALLOW_CLI_CORE_CHANGE=1 only after maintainer approval.')
process.exit(1)
}
await run(['bun', 'run', 'check:policy'])
if (result.checks.desktop) {
await run(['bun', 'run', 'check:desktop'])
}
if (result.checks.server) {
await run(['bun', 'run', 'check:server'])
}
if (result.checks.adapters) {
await run(['bun', 'run', 'check:adapters'])
}
if (result.checks.desktopNative) {
await run(['bun', 'run', 'check:native'])
}
if (result.checks.docs) {
await run(['bun', 'run', 'check:docs'])
}
console.log('\nPR local checks completed.')