* refactor: split smoke and integration test configs into separate CI jobs * fix: move CLA labeling from triage to CLA workflow * fix: install formatters in temp dir to avoid catalog: protocol error * fix: handle 404 when removing labels that don't exist on the PR
208 lines
7.9 KiB
YAML
208 lines
7.9 KiB
YAML
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 labels are managed by the CLA workflow (cla.yml), not here.
|
|
|
|
// --- 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',
|
|
'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));
|
|
|
|
// Helper: remove a label, ignoring 404 if it's already gone
|
|
async function safeRemoveLabel(name) {
|
|
try {
|
|
await github.rest.issues.removeLabel({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: pr.number,
|
|
name,
|
|
});
|
|
} catch (e) {
|
|
if (e.status !== 404) throw e;
|
|
}
|
|
}
|
|
|
|
// Remove stale size labels (only one size label at a time)
|
|
for (const sl of sizeLabels) {
|
|
if (sl !== sizeLabel && currentLabels.has(sl)) {
|
|
await safeRemoveLabel(sl);
|
|
}
|
|
}
|
|
|
|
// Remove needs-rebase if PR is now mergeable
|
|
if (!labels.has('needs-rebase') && currentLabels.has('needs-rebase')) {
|
|
await safeRemoveLabel('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 = '<!-- scope-guard -->';
|
|
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,
|
|
});
|
|
}
|
|
}
|