fix: handle symlinks when scanning user/project skill directories #107
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/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 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 (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 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') | |
| if ( | |
| filenames.some((file) => | |
| file.startsWith('desktop/src/') || | |
| file.startsWith('src/server/') || | |
| file.startsWith('src/tools/') || | |
| file.startsWith('src/utils/') || | |
| file.startsWith('adapters/') || | |
| file.startsWith('scripts/quality-gate/') || | |
| ['package.json', 'desktop/package.json', 'desktop/bun.lock'].includes(file) | |
| ) | |
| ) requiredChecks.push('coverage-checks') | |
| 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('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' || 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, | |
| '', | |
| '**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'), | |
| '', | |
| 'Hard merge gates still come from GitHub Actions, not AI review.', | |
| '', | |
| '**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.', | |
| ].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, | |
| }) | |
| } |