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