mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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
293 lines
13 KiB
YAML
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,
|
|
})
|
|
}
|