mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
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:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user