name: PR Triage on: pull_request_target: types: [opened, synchronize, reopened] permissions: contents: read pull-requests: write jobs: label: name: Label PR runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Triage PR uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: script: | const pr = context.payload.pull_request; const labels = new Set(); // --- Bot detection --- const botLogins = ['dependabot[bot]', 'renovate[bot]', 'emdashbot[bot]']; if (botLogins.includes(pr.user.login)) { labels.add('bot'); } // --- Size labels --- const { data: files } = await github.rest.pulls.listFiles({ owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number, per_page: 100, }); const linesChanged = files.reduce((sum, f) => sum + f.additions + f.deletions, 0); const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL']; let sizeLabel; if (linesChanged < 10) sizeLabel = 'size/XS'; else if (linesChanged < 50) sizeLabel = 'size/S'; else if (linesChanged < 200) sizeLabel = 'size/M'; else if (linesChanged < 500) sizeLabel = 'size/L'; else sizeLabel = 'size/XL'; labels.add(sizeLabel); // --- Area labels --- const areaMap = { 'area/core': (f) => f.startsWith('packages/core/'), 'area/admin': (f) => f.startsWith('packages/admin/'), 'area/plugins': (f) => f.startsWith('packages/plugins/'), 'area/docs': (f) => f.startsWith('docs/'), 'area/templates': (f) => f.startsWith('templates/'), 'area/ci': (f) => f.startsWith('.github/'), 'area/auth': (f) => f.startsWith('packages/auth/'), 'area/cloudflare': (f) => f.startsWith('packages/cloudflare/'), }; for (const file of files) { for (const [label, matcher] of Object.entries(areaMap)) { if (matcher(file.filename)) { labels.add(label); } } } // --- Merge conflict detection --- // mergeable is available on the full PR object (may need a separate fetch // since the webhook payload sometimes has mergeable=null while computing) try { const { data: fullPr } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number, }); if (fullPr.mergeable === false) { labels.add('needs-rebase'); } } catch { // Ignore -- mergeable state may not be computed yet } // --- CLA status --- // The CLA assistant sets a commit status named "license/cla". // Check the latest status for this PR's head SHA. try { const { data: statuses } = await github.rest.repos.listCommitStatusesForRef({ owner: context.repo.owner, repo: context.repo.repo, ref: pr.head.sha, }); const claStatus = statuses.find(s => s.context === 'license/cla'); if (claStatus) { if (claStatus.state === 'success') { labels.add('cla: signed'); } else { labels.add('cla: needed'); } } else { // CLA check hasn't run yet -- mark as pending labels.add('cla: needed'); } } catch { // If we can't read statuses, don't add CLA labels } // --- Ensure all labels exist, then apply --- const labelColors = { 'bot': 'ededed', 'size/XS': '3cbf00', 'size/S': '5dba3f', 'size/M': 'fbca04', 'size/L': 'ee9b00', 'size/XL': 'd93f0b', 'area/core': '0052cc', 'area/admin': '7057ff', 'area/plugins': '008672', 'area/docs': '0075ca', 'area/templates': 'bfdadc', 'area/ci': '000000', 'area/auth': 'd4c5f9', 'area/cloudflare': 'f9a825', 'cla: signed': '0e8a16', 'cla: needed': 'b60205', 'needs-rebase': 'e11d48', }; // Get existing labels on the repo const existingLabels = new Set(); for await (const response of github.paginate.iterator( github.rest.issues.listLabelsForRepo, { owner: context.repo.owner, repo: context.repo.repo, per_page: 100 } )) { for (const label of response.data) { existingLabels.add(label.name); } } // Create any missing labels for (const label of labels) { if (!existingLabels.has(label) && labelColors[label]) { await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label, color: labelColors[label], }); } } // Get current labels on the PR to remove stale ones const currentLabels = new Set(pr.labels.map(l => l.name)); // Remove stale size labels (only one size label at a time) for (const sl of sizeLabels) { if (sl !== sizeLabel && currentLabels.has(sl)) { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, name: sl, }); } } // Remove stale CLA labels const claLabels = ['cla: signed', 'cla: needed']; for (const cl of claLabels) { if (!labels.has(cl) && currentLabels.has(cl)) { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, name: cl, }); } } // Remove needs-rebase if PR is now mergeable if (!labels.has('needs-rebase') && currentLabels.has('needs-rebase')) { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, name: 'needs-rebase', }); } // Add new labels const toAdd = [...labels].filter(l => !currentLabels.has(l)); if (toAdd.length > 0) { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, labels: toAdd, }); } core.info(`Applied labels: ${[...labels].join(', ')}`); // --- Scope guard: comment on oversized PRs --- // Only on open (not synchronize) to avoid spamming on every push if (context.payload.action === 'opened') { const warnings = []; if (linesChanged > 500) { warnings.push(`This PR changes **${linesChanged.toLocaleString()} lines** across **${files.length} files**. Large PRs are harder to review and more likely to be closed without review.`); } else if (files.length > 20) { warnings.push(`This PR touches **${files.length} files**. PRs with a broad scope are harder to review. Please confirm the scope hasn't drifted beyond the intended change.`); } // Check for cross-area changes (touching multiple packages) const areas = Object.keys(areaMap).filter(a => labels.has(a)); if (areas.length > 3) { warnings.push(`This PR spans ${areas.length} different areas (${areas.join(', ')}). Consider breaking it into smaller, focused PRs.`); } if (warnings.length > 0) { const marker = ''; const body = [ marker, '## Scope check', '', ...warnings, '', 'If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.', '', 'See [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md) for contribution guidelines.', ].join('\n'); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, body, }); } }