cc-haha/.github/workflows/pr-triage.yml
程序员阿江(Relakkes) 435e4ccc9f ci: harden deterministic PR quality gates
Route required checks by changed surface, add offline provider and chat contracts, and keep fork PRs independent of live credentials. Layer agent guidance by subtree and enforce a compact instruction budget.

Tested: bun run check:policy
Confidence: high
Scope-risk: broad
2026-07-10 20:29:01 +08:00

293 lines
13 KiB
YAML

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',
'allow-missing-tests': 'c2e0c6',
'allow-coverage-baseline-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/electron/tsconfig.json',
'desktop/scripts/build-linux.sh',
'desktop/scripts/build-macos-arm64.sh',
'desktop/scripts/build-windows-x64.ps1',
])
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) &&
/\.[cm]?[jt]sx?$/.test(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/tools/') ||
file.startsWith('src/utils/')
)
const rootRuntimeProduct = changedProduct((file) =>
file.startsWith('src/') &&
!file.startsWith('src/server/') &&
!file.startsWith('src/tools/') &&
!file.startsWith('src/utils/')
)
const missingTestSignals = []
if (desktopProduct.length > 0 && !hasTest('desktop/src/')) {
missingTestSignals.push('Desktop product files changed without a desktop test file in the PR.')
}
if (serverProduct.length > 0 && !hasTest('src/server/')) {
missingTestSignals.push('Server product files changed without a server test file in the PR.')
}
if (adapterProduct.length > 0 && !hasTest('adapters/')) {
missingTestSignals.push('Adapter product files changed without an adapter test file in the PR.')
}
if (agentRuntimeProduct.length > 0 && !filenames.some((file) =>
(file.startsWith('src/tools/') || file.startsWith('src/utils/')) &&
(/\.test\.[cm]?[jt]sx?$/.test(file) || file.includes('/__tests__/'))
)) {
missingTestSignals.push('Agent/runtime product files changed without a tools/utils test file in the PR.')
}
if (rootRuntimeProduct.length > 0 && !filenames.some((file) =>
file.startsWith('src/') &&
!file.startsWith('src/server/') &&
!file.startsWith('src/tools/') &&
!file.startsWith('src/utils/') &&
(/\.test\.[cm]?[jt]sx?$/.test(file) || file.includes('/__tests__/'))
)) {
missingTestSignals.push('Root runtime product files changed without a matching root runtime test file in the PR.')
}
if (missingTestSignals.length > 0 && !currentLabels.has('allow-missing-tests')) {
areas.add('needs-maintainer-approval')
}
const coveragePolicyFiles = filenames.filter((file) =>
file === 'scripts/quality-gate/coverage-baseline.json' ||
file === 'scripts/quality-gate/coverage-thresholds.json'
)
if (coveragePolicyFiles.length > 0 && !currentLabels.has('allow-coverage-baseline-change')) {
areas.add('needs-maintainer-approval')
}
const testSignals = []
testSignals.push(...missingTestSignals.map((signal) => `BLOCKING unless \`allow-missing-tests\` is applied: ${signal}`))
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('Legacy native resources changed: inspect Electron sidecar/package output and package-smoke results.')
}
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' || label === 'allow-missing-tests' || label === 'allow-coverage-baseline-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 missingTestText = missingTestSignals.length > 0 && !currentLabels.has('allow-missing-tests')
? 'Blocked by policy until a maintainer applies `allow-missing-tests` or matching tests are added.'
: 'No missing-test policy block detected.'
const coveragePolicyText = coveragePolicyFiles.length > 0 && !currentLabels.has('allow-coverage-baseline-change')
? 'Blocked by policy until a maintainer applies `allow-coverage-baseline-change` after reviewing the baseline or threshold change.'
: 'No coverage-baseline policy block detected.'
const cliFilesText = cliCoreFiles.length
? cliCoreFiles.slice(0, 20).map((file) => `- \`${file}\``).join('\n')
: '- none'
const coveragePolicyFilesText = coveragePolicyFiles.length
? coveragePolicyFiles.slice(0, 20).map((file) => `- \`${file}\``).join('\n')
: '- none'
const body = [
marker,
'## PR quality triage',
'',
`**Changed areas:** ${areaText}`,
'',
`**CLI core policy:** ${approvalText}`,
'',
`**Missing-test policy:** ${missingTestText}`,
'',
`**Coverage baseline policy:** ${coveragePolicyText}`,
'',
'**CLI core files:**',
cliFilesText,
'',
'**Coverage policy files:**',
coveragePolicyFilesText,
'',
'**Required check plan:**',
'- See `PR Quality / scope-plan`; it is the single source of truth for selected jobs.',
'',
'**Test coverage signals:**',
testSignals.map((signal) => `- ${signal}`).join('\n'),
'',
'**Risk notes:**',
riskNotes.map((note) => `- ${note}`).join('\n'),
'',
'Hard merge gates come from the deterministic GitHub Actions contract lanes above.',
].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,
})
}