cc-haha/scripts/pr/change-policy.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

288 lines
7.0 KiB
TypeScript

#!/usr/bin/env bun
import { existsSync, readFileSync, appendFileSync } from 'node:fs'
export type ChangeArea =
| 'desktop'
| 'server'
| 'adapters'
| 'docs'
| 'release'
| 'cli-core'
export type ChangePolicyResult = {
files: string[]
labels: string[]
areas: ChangeArea[]
areaLabels: string[]
blocked: boolean
blockingReason: string | null
cliCoreFiles: string[]
checks: {
desktop: boolean
server: boolean
adapters: boolean
desktopNative: boolean
docs: boolean
}
}
const ALLOW_CLI_CORE_LABEL = 'allow-cli-core-change'
const areaLabels: Record<ChangeArea, string> = {
desktop: 'area:desktop',
server: 'area:server',
adapters: 'area:adapters',
docs: 'area:docs',
release: 'area:release',
'cli-core': 'area:cli-core',
}
const cliCorePrefixes = [
'bin/',
'src/entrypoints/',
'src/screens/',
'src/components/',
'src/commands/',
'src/tools/',
'src/utils/',
]
const desktopNativeExactPaths = new Set([
'bun.lock',
'package.json',
'desktop/bun.lock',
'desktop/package.json',
'desktop/package-lock.json',
'desktop/src-tauri/Cargo.lock',
'desktop/src-tauri/Cargo.toml',
'desktop/src-tauri/tauri.conf.json',
])
const docsExactPaths = new Set([
'README.md',
'README.en.md',
'package.json',
'package-lock.json',
'.github/workflows/deploy-docs.yml',
])
const releaseExactPaths = new Set([
'.github/workflows/pr-quality.yml',
'.github/workflows/pr-triage.yml',
'.github/workflows/release-desktop.yml',
'.github/workflows/build-desktop-dev.yml',
'scripts/pr/change-policy.ts',
'scripts/pr/change-policy.test.ts',
'scripts/pr/check-pr.ts',
'scripts/pr/run-server-tests.ts',
'scripts/release.ts',
'desktop/src-tauri/tauri.conf.json',
'desktop/src-tauri/Cargo.toml',
'desktop/src-tauri/Cargo.lock',
])
function normalizePath(path: string) {
return path.trim().replace(/\\/g, '/').replace(/^\.\//, '')
}
function startsWithAny(path: string, prefixes: string[]) {
return prefixes.some((prefix) => path.startsWith(prefix))
}
function isCliCorePath(path: string) {
return startsWithAny(path, cliCorePrefixes)
}
function areasForPath(path: string): ChangeArea[] {
const areas = new Set<ChangeArea>()
if (path.startsWith('desktop/')) {
areas.add('desktop')
}
if (path.startsWith('src/server/')) {
areas.add('server')
}
if (path.startsWith('adapters/')) {
areas.add('adapters')
}
if (
path.startsWith('docs/') ||
path.startsWith('release-notes/') ||
docsExactPaths.has(path)
) {
areas.add('docs')
}
if (releaseExactPaths.has(path)) {
areas.add('release')
}
if (isCliCorePath(path)) {
areas.add('cli-core')
}
return [...areas]
}
export function evaluateChangePolicy(
inputFiles: string[],
inputLabels: string[] = [],
): ChangePolicyResult {
const files = [...new Set(inputFiles.map(normalizePath).filter(Boolean))].sort()
const labels = [...new Set(inputLabels.map((label) => label.trim()).filter(Boolean))].sort()
const areas = new Set<ChangeArea>()
for (const file of files) {
for (const area of areasForPath(file)) {
areas.add(area)
}
}
const cliCoreFiles = files.filter(isCliCorePath)
const hasCliCoreChange = cliCoreFiles.length > 0
const hasCliCoreOverride = labels.includes(ALLOW_CLI_CORE_LABEL)
const blocked = hasCliCoreChange && !hasCliCoreOverride
const touchesDesktopNative = files.some((file) => (
file.startsWith('desktop/') ||
file.startsWith('adapters/') ||
file.startsWith('src/server/') ||
desktopNativeExactPaths.has(file)
))
const touchesDocs = files.some((file) => (
file.startsWith('docs/') ||
file.startsWith('release-notes/') ||
docsExactPaths.has(file)
))
const orderedAreas = [...areas].sort()
return {
files,
labels,
areas: orderedAreas,
areaLabels: orderedAreas.map((area) => areaLabels[area]),
blocked,
blockingReason: blocked
? `CLI core changes require the ${ALLOW_CLI_CORE_LABEL} label and maintainer approval.`
: null,
cliCoreFiles,
checks: {
desktop: areas.has('desktop') || areas.has('server'),
server: areas.has('server') || files.some((file) => file.startsWith('src/tools/') || file.startsWith('src/utils/')),
adapters: areas.has('adapters'),
desktopNative: touchesDesktopNative,
docs: touchesDocs,
},
}
}
function parseArgs(argv: string[]) {
const args = new Map<string, string>()
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (!arg.startsWith('--')) {
continue
}
const next = argv[index + 1]
if (next && !next.startsWith('--')) {
args.set(arg, next)
index += 1
} else {
args.set(arg, 'true')
}
}
return args
}
function readListFile(path: string) {
if (!existsSync(path)) {
throw new Error(`Missing file: ${path}`)
}
return readFileSync(path, 'utf8')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
}
function formatSummary(result: ChangePolicyResult) {
const lines = [
'PR change policy',
` Areas: ${result.areas.length ? result.areas.join(', ') : 'none'}`,
` Labels: ${result.labels.length ? result.labels.join(', ') : 'none'}`,
` Checks: desktop=${result.checks.desktop}, server=${result.checks.server}, adapters=${result.checks.adapters}, desktopNative=${result.checks.desktopNative}, docs=${result.checks.docs}`,
]
if (result.cliCoreFiles.length > 0) {
lines.push(' CLI core files:')
for (const file of result.cliCoreFiles) {
lines.push(` - ${file}`)
}
}
if (result.blockingReason) {
lines.push(` Blocked: ${result.blockingReason}`)
}
return lines.join('\n')
}
function writeGithubOutputs(result: ChangePolicyResult) {
const outputPath = process.env.GITHUB_OUTPUT
if (!outputPath) {
return
}
const outputs = {
areas: result.areas.join(','),
area_labels: result.areaLabels.join(','),
blocked: String(result.blocked),
desktop_checks: String(result.checks.desktop),
server_checks: String(result.checks.server),
adapter_checks: String(result.checks.adapters),
desktop_native_checks: String(result.checks.desktopNative),
docs_checks: String(result.checks.docs),
}
appendFileSync(
outputPath,
Object.entries(outputs)
.map(([key, value]) => `${key}=${value}`)
.join('\n') + '\n',
)
}
if (import.meta.main) {
const args = parseArgs(process.argv.slice(2))
const filesPath = args.get('--files')
const labelsPath = args.get('--labels-file')
const labelsArg = args.get('--labels')
if (!filesPath) {
console.error('Usage: bun run scripts/pr/change-policy.ts --files <changed-files.txt> [--labels-file <labels.txt>]')
process.exit(2)
}
const files = readListFile(filesPath)
const labels = labelsPath
? readListFile(labelsPath)
: labelsArg?.split(',').map((label) => label.trim()).filter(Boolean) ?? []
const result = evaluateChangePolicy(files, labels)
console.log(formatSummary(result))
writeGithubOutputs(result)
if (result.blocked) {
process.exit(1)
}
}