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 = '' 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, }) }