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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-02 11:01:07 +08:00
parent c13fa75c3b
commit fdf5fe2653
9 changed files with 1204 additions and 0 deletions

13
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,13 @@
## Summary
## Verification
- [ ] I ran the relevant local checks, or explained why they do not apply.
## Risk
- [ ] This PR does not touch CLI core paths, or it has maintainer approval for `allow-cli-core-change`.
- [ ] User-facing behavior changes include tests or an explanation of why tests are not practical.
@dosubot review this PR for changed-area risk, missing tests, docs impact, desktop startup risk, and CLI core impact.

181
.github/workflows/pr-quality.yml vendored Normal file
View File

@ -0,0 +1,181 @@
name: PR Quality
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
permissions:
contents: read
pull-requests: read
concurrency:
group: pr-quality-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
change-policy:
name: change-policy
runs-on: ubuntu-latest
outputs:
areas: ${{ steps.policy.outputs.areas }}
area_labels: ${{ steps.policy.outputs.area_labels }}
blocked: ${{ steps.policy.outputs.blocked }}
desktop_checks: ${{ steps.policy.outputs.desktop_checks }}
server_checks: ${{ steps.policy.outputs.server_checks }}
adapter_checks: ${{ steps.policy.outputs.adapter_checks }}
desktop_native_checks: ${{ steps.policy.outputs.desktop_native_checks }}
docs_checks: ${{ steps.policy.outputs.docs_checks }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Run policy tests
run: bun run check:policy
- name: Collect changed files
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename' > changed-files.txt
jq -r '.pull_request.labels[].name' "$GITHUB_EVENT_PATH" > labels.txt
- name: Evaluate change policy
id: policy
run: bun run scripts/pr/change-policy.ts --files changed-files.txt --labels-file labels.txt
desktop-checks:
name: desktop-checks
needs: change-policy
if: needs.change-policy.outputs.desktop_checks == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install root dependencies
run: bun install
- name: Install desktop dependencies
working-directory: desktop
run: bun install
- name: Run desktop checks
run: bun run check:desktop
server-checks:
name: server-checks
needs: change-policy
if: needs.change-policy.outputs.server_checks == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install root dependencies
run: bun install
- name: Run server checks
run: bun run check:server
adapter-checks:
name: adapter-checks
needs: change-policy
if: needs.change-policy.outputs.adapter_checks == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install adapter dependencies
working-directory: adapters
run: bun install
- name: Run adapter checks
run: bun run check:adapters
desktop-native-checks:
name: desktop-native-checks
needs: change-policy
if: needs.change-policy.outputs.desktop_native_checks == 'true'
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential curl wget file \
libxdo-dev libssl-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev patchelf \
libfuse2
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: 'desktop/src-tauri -> target'
- name: Install root dependencies
run: bun install
- name: Install desktop dependencies
working-directory: desktop
run: bun install
- name: Install adapter dependencies
working-directory: adapters
run: bun install
- name: Run desktop native checks
run: bun run check:native
docs-checks:
name: docs-checks
needs: change-policy
if: needs.change-policy.outputs.docs_checks == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Run docs checks
run: npm ci && npm run docs:build

259
.github/workflows/pr-triage.yml vendored Normal file
View File

@ -0,0 +1,259 @@
name: PR Triage
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: pr-triage-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
triage:
name: label-and-summarize
runs-on: ubuntu-latest
steps:
- name: Apply deterministic PR labels and summary
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner
const repo = context.repo.repo
const issue_number = context.payload.pull_request.number
const managedLabels = {
'area:desktop': '1d76db',
'area:server': '0e8a16',
'area:adapters': '5319e7',
'area:docs': '0075ca',
'area:release': 'fbca04',
'area:cli-core': 'b60205',
'needs-maintainer-approval': 'b60205',
'allow-cli-core-change': 'c2e0c6',
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: issue_number,
per_page: 100,
})
const filenames = files.map((file) => file.filename)
const currentLabels = new Set((context.payload.pull_request.labels || []).map((label) => label.name))
const areas = new Set()
const cliCoreFiles = []
const cliPrefixes = [
'bin/',
'src/entrypoints/',
'src/screens/',
'src/components/',
'src/commands/',
'src/tools/',
'src/utils/',
]
const docsExact = new Set(['README.md', 'README.en.md', 'package.json', 'package-lock.json', '.github/workflows/deploy-docs.yml'])
const releaseExact = 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',
])
for (const filename of filenames) {
if (filename.startsWith('desktop/')) areas.add('area:desktop')
if (filename.startsWith('src/server/')) areas.add('area:server')
if (filename.startsWith('adapters/')) areas.add('area:adapters')
if (filename.startsWith('docs/') || filename.startsWith('release-notes/') || docsExact.has(filename)) areas.add('area:docs')
if (releaseExact.has(filename)) areas.add('area:release')
if (cliPrefixes.some((prefix) => filename.startsWith(prefix))) {
areas.add('area:cli-core')
cliCoreFiles.push(filename)
}
}
if (cliCoreFiles.length > 0 && !currentLabels.has('allow-cli-core-change')) {
areas.add('needs-maintainer-approval')
}
const hasTest = (prefix) => filenames.some((file) =>
file.startsWith(prefix) && (/\.test\.[cm]?[jt]sx?$/.test(file) || file.includes('/__tests__/'))
)
const changedProduct = (predicate) => filenames.filter((file) =>
predicate(file) &&
!/\.test\.[cm]?[jt]sx?$/.test(file) &&
!file.includes('/__tests__/') &&
!file.includes('/fixtures/')
)
const desktopProduct = changedProduct((file) => file.startsWith('desktop/src/'))
const serverProduct = changedProduct((file) => file.startsWith('src/server/'))
const adapterProduct = changedProduct((file) => file.startsWith('adapters/'))
const agentRuntimeProduct = changedProduct((file) =>
file.startsWith('src/server/ws/') ||
file.startsWith('src/server/services/conversation') ||
file.startsWith('src/tools/') ||
file.startsWith('src/utils/')
)
const requiredChecks = ['change-policy']
if (areas.has('area:desktop') || areas.has('area:server')) requiredChecks.push('desktop-checks')
if (areas.has('area:server') || filenames.some((file) => file.startsWith('src/tools/') || file.startsWith('src/utils/'))) requiredChecks.push('server-checks')
if (areas.has('area:adapters')) requiredChecks.push('adapter-checks')
if (
filenames.some((file) =>
file.startsWith('desktop/') ||
file.startsWith('adapters/') ||
file.startsWith('src/server/') ||
['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'].includes(file)
)
) requiredChecks.push('desktop-native-checks')
if (areas.has('area:docs')) requiredChecks.push('docs-checks')
const testSignals = []
if (desktopProduct.length > 0 && !hasTest('desktop/src/')) {
testSignals.push('Desktop product files changed without a desktop test file in the PR.')
}
if (serverProduct.length > 0 && !hasTest('src/server/')) {
testSignals.push('Server product files changed without a server test file in the PR.')
}
if (adapterProduct.length > 0 && !hasTest('adapters/')) {
testSignals.push('Adapter product files changed without an adapter test file in the PR.')
}
if (agentRuntimeProduct.length > 0) {
testSignals.push('Agent/model runtime path changed: use mock/request-shape tests in PR and maintainer live-model smoke before release.')
}
if (testSignals.length === 0) {
testSignals.push('No obvious missing-test signal from changed paths.')
}
const riskNotes = []
if (filenames.some((file) => file.startsWith('desktop/src-tauri/'))) {
riskNotes.push('Tauri/native code changed: inspect sidecar build and cargo check.')
}
if (filenames.some((file) => file.startsWith('desktop/src/stores/') || file.startsWith('desktop/src/api/'))) {
riskNotes.push('Desktop state/API layer changed: verify store persistence, WebSocket behavior, and startup errors.')
}
if (filenames.some((file) => file.startsWith('src/server/ws/') || file.startsWith('src/server/services/conversation'))) {
riskNotes.push('Session runtime changed: review reconnect, startup diagnostics, provider selection, and thinking settings.')
}
if (filenames.some((file) => file.includes('provider') || file.includes('WebSearchTool'))) {
riskNotes.push('Provider/search behavior changed: PR gate uses mock tests; live-provider tests stay maintainer-only.')
}
if (filenames.some((file) => file.startsWith('.github/workflows/') || file.startsWith('scripts/pr/'))) {
riskNotes.push('CI/policy changed: inspect workflow behavior itself, not just application tests.')
}
if (riskNotes.length === 0) {
riskNotes.push('No special risk notes from changed paths.')
}
for (const [name, color] of Object.entries(managedLabels)) {
try {
await github.rest.issues.createLabel({
owner,
repo,
name,
color,
})
} catch (error) {
if (error.status !== 422) throw error
}
}
const desiredLabels = [...areas]
if (desiredLabels.length > 0) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: desiredLabels,
})
}
for (const label of Object.keys(managedLabels)) {
if (label === 'allow-cli-core-change') continue
if (!areas.has(label) && currentLabels.has(label)) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number,
name: label,
})
} catch (error) {
if (error.status !== 404) throw error
}
}
}
const marker = '<!-- pr-quality-triage -->'
const areaText = [...areas].filter((label) => label.startsWith('area:')).sort().join(', ') || 'none'
const approvalText = cliCoreFiles.length > 0 && !currentLabels.has('allow-cli-core-change')
? 'Blocked by policy until a maintainer applies `allow-cli-core-change` and approves the PR.'
: 'No CLI-core policy block detected.'
const cliFilesText = cliCoreFiles.length
? cliCoreFiles.slice(0, 20).map((file) => `- \`${file}\``).join('\n')
: '- none'
const body = [
marker,
'## PR quality triage',
'',
`**Changed areas:** ${areaText}`,
'',
`**CLI core policy:** ${approvalText}`,
'',
'**CLI core files:**',
cliFilesText,
'',
'**Expected checks:**',
requiredChecks.map((check) => `- \`${check}\``).join('\n'),
'',
'**Test coverage signals:**',
testSignals.map((signal) => `- ${signal}`).join('\n'),
'',
'**Risk notes:**',
riskNotes.map((note) => `- ${note}`).join('\n'),
'',
'**Dosu handoff:** Dosu can be used as the AI reviewer for risk explanation, missing-test prompts, and maintainer Q&A. If it does not comment automatically from the PR template, ask:',
'',
'`@dosubot review this PR for changed-area risk, missing tests, docs impact, desktop startup risk, and CLI core impact.`',
'',
'Hard merge gates still come from GitHub Actions, not AI review.',
].join('\n')
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
})
const existing = comments.find((comment) => comment.body && comment.body.includes(marker))
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
})
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
})
}

View File

@ -9,6 +9,14 @@
"scripts": {
"claude-haha": "bun run ./bin/claude-haha",
"start": "bun run ./bin/claude-haha",
"check:pr": "bun run scripts/pr/check-pr.ts",
"check:impact": "bun run scripts/pr/impact-report.ts",
"check:policy": "bun test scripts/pr/change-policy.test.ts",
"check:server": "bun run scripts/pr/run-server-tests.ts",
"check:desktop": "cd desktop && bun run lint && bun run test -- --run && bun run build",
"check:adapters": "cd adapters && bun test",
"check:native": "cd desktop && bun run build:sidecars && cd src-tauri && cargo check",
"check:docs": "npm ci && npm run docs:build",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"

View File

@ -0,0 +1,62 @@
import { describe, expect, test } from 'bun:test'
import { evaluateChangePolicy } from './change-policy'
describe('evaluateChangePolicy', () => {
test('blocks CLI core changes without an override label', () => {
const result = evaluateChangePolicy([
'src/commands/help.ts',
'desktop/src/pages/Settings.tsx',
])
expect(result.blocked).toBe(true)
expect(result.areas).toContain('cli-core')
expect(result.areas).toContain('desktop')
expect(result.areaLabels).toContain('area:cli-core')
expect(result.areaLabels).toContain('area:desktop')
expect(result.cliCoreFiles).toEqual(['src/commands/help.ts'])
})
test('allows CLI core changes with a maintainer override label', () => {
const result = evaluateChangePolicy(
['src/tools/WebSearchTool/backend.ts'],
['allow-cli-core-change'],
)
expect(result.blocked).toBe(false)
expect(result.areas).toEqual(['cli-core'])
expect(result.checks.server).toBe(true)
})
test('keeps docs-only changes on the docs lane', () => {
const result = evaluateChangePolicy([
'docs/index.md',
'README.md',
])
expect(result.blocked).toBe(false)
expect(result.areas).toEqual(['docs'])
expect(result.checks.docs).toBe(true)
expect(result.checks.desktop).toBe(false)
expect(result.checks.desktopNative).toBe(false)
})
test('routes desktop and server changes to desktop and native checks', () => {
const result = evaluateChangePolicy([
'desktop/src/pages/Settings.tsx',
'src/server/ws/handler.ts',
])
expect(result.areas).toEqual(['desktop', 'server'])
expect(result.checks.desktop).toBe(true)
expect(result.checks.server).toBe(true)
expect(result.checks.desktopNative).toBe(true)
})
test('routes adapter changes to adapter and native checks', () => {
const result = evaluateChangePolicy(['adapters/telegram/index.ts'])
expect(result.areas).toEqual(['adapters'])
expect(result.checks.adapters).toBe(true)
expect(result.checks.desktopNative).toBe(true)
})
})

287
scripts/pr/change-policy.ts Normal file
View File

@ -0,0 +1,287 @@
#!/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)
}
}

114
scripts/pr/check-pr.ts Normal file
View File

@ -0,0 +1,114 @@
#!/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.')

220
scripts/pr/impact-report.ts Normal file
View File

@ -0,0 +1,220 @@
#!/usr/bin/env bun
import { evaluateChangePolicy } from './change-policy'
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)
}
function parseListArg(name: string) {
const index = process.argv.indexOf(name)
if (index === -1) {
return []
}
const value = process.argv[index + 1]
if (!value || value.startsWith('--')) {
return []
}
return value.split(',').map((item) => item.trim()).filter(Boolean)
}
async function changedFiles() {
const files = parseListArg('--files')
if (files.length > 0) {
return files
}
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)]
}
}
}
function commandList(result: ReturnType<typeof evaluateChangePolicy>) {
const commands = ['bun run check:policy']
if (result.checks.desktop) {
commands.push('bun run check:desktop')
}
if (result.checks.server) {
commands.push('bun run check:server')
}
if (result.checks.adapters) {
commands.push('bun run check:adapters')
}
if (result.checks.desktopNative) {
commands.push('bun run check:native')
}
if (result.checks.docs) {
commands.push('bun run check:docs')
}
return commands
}
function hasMatchingTest(files: string[], predicate: (file: string) => boolean) {
return files.some((file) => (
predicate(file) &&
(/\.test\.[cm]?[jt]sx?$/.test(file) || file.includes('/__tests__/'))
))
}
function changedProductionFiles(files: string[], predicate: (file: string) => boolean) {
return files.filter((file) => (
predicate(file) &&
!/\.test\.[cm]?[jt]sx?$/.test(file) &&
!file.includes('/__tests__/') &&
!file.includes('/fixtures/')
))
}
function coverageWarnings(files: string[]) {
const warnings: string[] = []
const desktopProd = changedProductionFiles(files, (file) => file.startsWith('desktop/src/'))
const serverProd = changedProductionFiles(files, (file) => file.startsWith('src/server/'))
const adapterProd = changedProductionFiles(files, (file) => file.startsWith('adapters/'))
const agentRuntimeProd = changedProductionFiles(files, (file) => (
file.startsWith('src/server/ws/') ||
file.startsWith('src/server/services/conversation') ||
file.startsWith('src/tools/') ||
file.startsWith('src/utils/')
))
if (desktopProd.length > 0 && !hasMatchingTest(files, (file) => file.startsWith('desktop/src/'))) {
warnings.push('Desktop product files changed without a desktop test file in the PR.')
}
if (serverProd.length > 0 && !hasMatchingTest(files, (file) => file.startsWith('src/server/'))) {
warnings.push('Server product files changed without a server test file in the PR.')
}
if (adapterProd.length > 0 && !hasMatchingTest(files, (file) => file.startsWith('adapters/'))) {
warnings.push('Adapter product files changed without an adapter test file in the PR.')
}
if (agentRuntimeProd.length > 0) {
warnings.push('Agent/model runtime path changed: prefer request-shape/mock tests in PR and maintainer live-model smoke before release.')
}
return warnings
}
function riskNotes(files: string[]) {
const notes: string[] = []
if (files.some((file) => file.startsWith('desktop/src-tauri/'))) {
notes.push('Tauri/native code changed: check sidecar build and cargo check output closely.')
}
if (files.some((file) => file.startsWith('desktop/src/stores/') || file.startsWith('desktop/src/api/'))) {
notes.push('Desktop state/API layer changed: verify store persistence, WebSocket behavior, and startup errors.')
}
if (files.some((file) => file.startsWith('src/server/ws/') || file.startsWith('src/server/services/conversation'))) {
notes.push('Session runtime changed: review reconnect, startup diagnostics, provider selection, and thinking settings.')
}
if (files.some((file) => file.includes('provider') || file.includes('WebSearchTool'))) {
notes.push('Provider/search behavior changed: PR gate uses mock tests; live-provider tests should stay maintainer-only.')
}
if (files.some((file) => file.startsWith('.github/workflows/') || file.startsWith('scripts/pr/'))) {
notes.push('CI/policy changed: inspect the PR workflow behavior itself, not just application tests.')
}
return notes
}
const labels = [
...parseListArg('--labels'),
...(process.env.PR_LABELS?.split(',').map((label) => label.trim()).filter(Boolean) ?? []),
]
if (process.env.ALLOW_CLI_CORE_CHANGE === '1') {
labels.push('allow-cli-core-change')
}
const files = await changedFiles()
const result = evaluateChangePolicy(files, labels)
const commands = commandList(result)
const warnings = coverageWarnings(result.files)
const notes = riskNotes(result.files)
console.log('# PR impact report')
console.log('')
console.log(`Changed files: ${result.files.length}`)
console.log(`Areas: ${result.areas.length ? result.areas.join(', ') : 'none'}`)
console.log(`Labels: ${result.labels.length ? result.labels.join(', ') : 'none'}`)
console.log(`Blocked: ${result.blocked ? 'yes' : 'no'}`)
if (result.blockingReason) {
console.log(`Blocking reason: ${result.blockingReason}`)
}
console.log('')
console.log('## Required local checks')
for (const command of commands) {
console.log(`- \`${command}\``)
}
console.log('')
console.log('## Test coverage signals')
if (warnings.length === 0) {
console.log('- No obvious missing-test signal from changed paths.')
} else {
for (const warning of warnings) {
console.log(`- ${warning}`)
}
}
console.log('')
console.log('## Risk notes')
if (notes.length === 0) {
console.log('- No special risk notes from changed paths.')
} else {
for (const note of notes) {
console.log(`- ${note}`)
}
}
console.log('')
console.log('## Agent/model testing policy')
console.log('- Default PR gate should not call real models or live providers.')
console.log('- Cover agent behavior with mock CLI, request-shape assertions, transcript fixtures, and provider capability tests.')
console.log('- Run live-model smoke tests only in maintainer-controlled workflows with secrets, rate limits, and explicit labels.')

View File

@ -0,0 +1,60 @@
#!/usr/bin/env bun
import { readdirSync, statSync } from 'node:fs'
import { join, relative, sep } from 'node:path'
const root = process.cwd()
const roots = ['src/server', 'src/tools', 'src/utils']
const excludedFiles = new Set([
// These suites are not stable enough for the default PR gate yet. Keep them
// out of CI until they are fixed or moved to a maintainer-only workflow.
'src/server/__tests__/cron-scheduler.test.ts',
'src/server/__tests__/providers-real.test.ts',
'src/server/__tests__/tasks.test.ts',
'src/server/__tests__/e2e/business-flow.test.ts',
'src/server/__tests__/e2e/full-flow.test.ts',
])
function normalize(path: string) {
return relative(root, path).split(sep).join('/')
}
function walk(path: string, files: string[]) {
const stat = statSync(path)
if (stat.isDirectory()) {
for (const entry of readdirSync(path)) {
walk(join(path, entry), files)
}
return
}
if (!stat.isFile()) {
return
}
const normalized = normalize(path)
if (normalized.endsWith('.test.ts') && !excludedFiles.has(normalized)) {
files.push(normalized)
}
}
const testFiles: string[] = []
for (const testRoot of roots) {
walk(join(root, testRoot), testFiles)
}
testFiles.sort()
if (testFiles.length === 0) {
console.log('No server-side test files found.')
process.exit(0)
}
const proc = Bun.spawn(['bun', 'test', ...testFiles], {
cwd: root,
stdout: 'inherit',
stderr: 'inherit',
})
process.exit(await proc.exited)