chore: add PR automation workflows for triage, formatting, staleness, and overlap detection (#263)
This commit is contained in:
240
.github/workflows/pr-triage.yml
vendored
Normal file
240
.github/workflows/pr-triage.yml
vendored
Normal file
@@ -0,0 +1,240 @@
|
||||
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 = '<!-- 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user