chore: add PR automation workflows for triage, formatting, staleness, and overlap detection (#263)
This commit is contained in:
288
.github/workflows/pr-sweep.yml
vendored
Normal file
288
.github/workflows/pr-sweep.yml
vendored
Normal file
@@ -0,0 +1,288 @@
|
||||
name: PR Sweep
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 6 hours
|
||||
- cron: "0 */6 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
name: Sweep Open PRs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Sweep PRs
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const now = new Date();
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Label color map for auto-creation
|
||||
const labelColors = {
|
||||
'needs-approval': 'fbca04',
|
||||
'needs-rebase': 'e11d48',
|
||||
'stale': 'ededed',
|
||||
'overlap': 'c5def5',
|
||||
};
|
||||
|
||||
// Ensure labels exist
|
||||
const existingLabels = new Set();
|
||||
for await (const response of github.paginate.iterator(
|
||||
github.rest.issues.listLabelsForRepo,
|
||||
{ owner, repo, per_page: 100 }
|
||||
)) {
|
||||
for (const label of response.data) {
|
||||
existingLabels.add(label.name);
|
||||
}
|
||||
}
|
||||
for (const [name, color] of Object.entries(labelColors)) {
|
||||
if (!existingLabels.has(name)) {
|
||||
await github.rest.issues.createLabel({ owner, repo, name, color });
|
||||
}
|
||||
}
|
||||
|
||||
// Get all open PRs
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
core.info(`Sweeping ${prs.length} open PRs`);
|
||||
|
||||
// Build a file->PR map for overlap detection
|
||||
const fileToPRs = new Map();
|
||||
|
||||
for (const pr of prs) {
|
||||
const prLabels = new Set(pr.labels.map(l => l.name));
|
||||
const toAdd = [];
|
||||
const toRemove = [];
|
||||
|
||||
// --- needs-approval: PR has no CI check runs ---
|
||||
// (CLA runs via pull_request_target so it always runs)
|
||||
try {
|
||||
const { data: checkRuns } = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: pr.head.sha,
|
||||
per_page: 100,
|
||||
});
|
||||
// Filter to only CI-related checks (not CLA, not PR Triage)
|
||||
const ciChecks = checkRuns.check_runs.filter(c =>
|
||||
!c.name.includes('CLA') &&
|
||||
!c.name.includes('Label') &&
|
||||
!c.name.includes('Triage') &&
|
||||
!c.name.includes('Validate PR')
|
||||
);
|
||||
const prAge = now - new Date(pr.created_at);
|
||||
|
||||
if (ciChecks.length === 0 && prAge > 5 * 60 * 1000) {
|
||||
// No CI checks after 5 minutes -- likely needs approval
|
||||
if (!prLabels.has('needs-approval')) toAdd.push('needs-approval');
|
||||
} else {
|
||||
if (prLabels.has('needs-approval')) toRemove.push('needs-approval');
|
||||
}
|
||||
} catch {
|
||||
// Ignore check run lookup failures
|
||||
}
|
||||
|
||||
// --- needs-rebase: PR has merge conflicts ---
|
||||
// mergeable is not included in the list endpoint; fetch individually
|
||||
try {
|
||||
const { data: fullPr } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
if (fullPr.mergeable === false) {
|
||||
if (!prLabels.has('needs-rebase')) toAdd.push('needs-rebase');
|
||||
} else if (fullPr.mergeable === true) {
|
||||
if (prLabels.has('needs-rebase')) toRemove.push('needs-rebase');
|
||||
}
|
||||
// mergeable===null means GitHub is still computing; skip
|
||||
} catch {
|
||||
// Ignore merge status lookup failures
|
||||
}
|
||||
|
||||
// --- Stale detection ---
|
||||
const lastActivity = new Date(pr.updated_at);
|
||||
const daysSinceActivity = (now - lastActivity) / DAY;
|
||||
|
||||
if (daysSinceActivity > 21) {
|
||||
// 21 days with no activity -- close it
|
||||
if (!prLabels.has('stale')) {
|
||||
// Should already be labeled stale from 14-day mark, but just in case
|
||||
toAdd.push('stale');
|
||||
}
|
||||
|
||||
const marker = '<!-- stale-close -->';
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body: `${marker}\nThis PR has been inactive for 21 days and is being closed automatically. If you'd like to continue working on it, feel free to reopen it.`,
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed',
|
||||
});
|
||||
|
||||
core.info(`#${pr.number}: closed (stale, ${Math.floor(daysSinceActivity)} days inactive)`);
|
||||
continue; // Skip further processing for closed PRs
|
||||
} else if (daysSinceActivity > 14) {
|
||||
// 14 days -- label as stale and warn
|
||||
if (!prLabels.has('stale')) {
|
||||
toAdd.push('stale');
|
||||
|
||||
const marker = '<!-- stale-warning -->';
|
||||
// Check if we already left a stale warning
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
per_page: 10,
|
||||
});
|
||||
const hasWarning = comments.some(c =>
|
||||
c.user?.login === 'github-actions[bot]' && c.body?.includes(marker)
|
||||
);
|
||||
|
||||
if (!hasWarning) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body: `${marker}\nThis PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.\n\nIf you're still working on this, please push an update or leave a comment.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Active -- remove stale label if present
|
||||
if (prLabels.has('stale')) toRemove.push('stale');
|
||||
}
|
||||
|
||||
// --- Collect files for overlap detection ---
|
||||
try {
|
||||
const { data: files } = await github.rest.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
for (const file of files) {
|
||||
if (!fileToPRs.has(file.filename)) {
|
||||
fileToPRs.set(file.filename, []);
|
||||
}
|
||||
fileToPRs.get(file.filename).push(pr.number);
|
||||
}
|
||||
} catch {
|
||||
// Ignore file listing failures
|
||||
}
|
||||
|
||||
// --- Apply label changes ---
|
||||
if (toAdd.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
labels: toAdd,
|
||||
});
|
||||
}
|
||||
for (const label of toRemove) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
name: label,
|
||||
});
|
||||
} catch {
|
||||
// Label might not exist on the PR
|
||||
}
|
||||
}
|
||||
|
||||
if (toAdd.length > 0 || toRemove.length > 0) {
|
||||
core.info(`#${pr.number}: +[${toAdd.join(',')}] -[${toRemove.join(',')}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Overlap detection ---
|
||||
// Find files changed by multiple PRs
|
||||
const overlaps = new Map(); // pr number -> set of overlapping PR numbers
|
||||
for (const [file, prNumbers] of fileToPRs) {
|
||||
if (prNumbers.length > 1) {
|
||||
for (const prNum of prNumbers) {
|
||||
if (!overlaps.has(prNum)) overlaps.set(prNum, new Set());
|
||||
for (const other of prNumbers) {
|
||||
if (other !== prNum) overlaps.get(prNum).add(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Label PRs with significant overlap (3+ shared files with another PR)
|
||||
for (const [prNum, otherPRs] of overlaps) {
|
||||
// Count shared files per overlapping PR
|
||||
const sharedFileCounts = new Map();
|
||||
for (const [file, prNumbers] of fileToPRs) {
|
||||
if (prNumbers.includes(prNum)) {
|
||||
for (const other of prNumbers) {
|
||||
if (other !== prNum) {
|
||||
sharedFileCounts.set(other, (sharedFileCounts.get(other) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const significantOverlaps = [...sharedFileCounts.entries()]
|
||||
.filter(([_, count]) => count >= 3)
|
||||
.map(([otherPR, count]) => `#${otherPR} (${count} shared files)`);
|
||||
|
||||
if (significantOverlaps.length > 0) {
|
||||
const pr = prs.find(p => p.number === prNum);
|
||||
const prLabels = new Set(pr?.labels.map(l => l.name) || []);
|
||||
|
||||
if (!prLabels.has('overlap')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNum,
|
||||
labels: ['overlap'],
|
||||
});
|
||||
|
||||
// Leave a comment about the overlap (only once)
|
||||
const marker = '<!-- overlap-notice -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNum,
|
||||
per_page: 20,
|
||||
});
|
||||
const hasNotice = comments.some(c =>
|
||||
c.user?.login === 'github-actions[bot]' && c.body?.includes(marker)
|
||||
);
|
||||
|
||||
if (!hasNotice) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNum,
|
||||
body: `${marker}\n## Overlapping PRs\n\nThis PR modifies files that are also changed by other open PRs:\n\n${significantOverlaps.map(s => `- ${s}`).join('\n')}\n\nThis may cause merge conflicts or duplicated work. A maintainer will coordinate.`,
|
||||
});
|
||||
}
|
||||
|
||||
core.info(`#${prNum}: overlap with ${significantOverlaps.join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user