PR Sweep #283
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 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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| 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(', ')}`); | |
| } | |
| } | |
| } |