Hourly Seed PR Cleanup #1114
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: Hourly Seed PR Cleanup | |
| on: | |
| schedule: | |
| - cron: "17 * * * *" | |
| workflow_dispatch: | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| jobs: | |
| cleanup-seed-pr: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Close non-seed PRs hourly; close seed PRs only after 12h | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const seedLabel = "lab-seed-pr"; | |
| const now = new Date(); | |
| const { data: repoInfo } = await github.rest.repos.get({ owner, repo }); | |
| const defaultBranch = repoInfo.default_branch; | |
| const deleteSameRepoBranch = async (pr, why) => { | |
| const headRef = pr?.head?.ref; | |
| const headRepoFullName = pr?.head?.repo?.full_name; | |
| if (!headRef) { | |
| core.warning(`PR #${pr.number}: missing head ref; cannot delete branch.`); | |
| return; | |
| } | |
| if (headRef === defaultBranch) { | |
| core.warning(`PR #${pr.number}: head ref is default branch (${defaultBranch}); skipping deletion.`); | |
| return; | |
| } | |
| if (headRepoFullName !== `${owner}/${repo}`) { | |
| core.info(`PR #${pr.number}: head branch is in a fork (${headRepoFullName}); cannot delete from this repo.`); | |
| return; | |
| } | |
| const ref = `heads/${headRef}`; | |
| try { | |
| await github.rest.git.deleteRef({ owner, repo, ref }); | |
| core.info(`PR #${pr.number}: deleted branch ${ref}${why ? ` (${why})` : ""}.`); | |
| } catch (e) { | |
| const status = e?.status; | |
| if (status === 404 || status === 422) { | |
| core.info(`PR #${pr.number}: branch ${ref} already gone or cannot be deleted (${status}).`); | |
| return; | |
| } | |
| core.warning(`PR #${pr.number}: could not delete ${ref}: ${e.message}`); | |
| } | |
| }; | |
| const pulls = await github.paginate(github.rest.pulls.list, { | |
| owner, repo, state: "open", per_page: 100 | |
| }); | |
| for (const pr of pulls) { | |
| const labels = (pr.labels || []).map(l => l.name); | |
| const isSeed = labels.includes(seedLabel); | |
| const created = new Date(pr.created_at); | |
| const ageHours = (now - created) / (1000 * 60 * 60); | |
| if (isSeed && ageHours < 12) continue; | |
| const reason = isSeed | |
| ? "Auto-closed by hourly cleanup after 12h seed lifetime." | |
| : "Auto-closed by hourly cleanup (non-seed PR)."; | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number: pr.number, | |
| body: reason | |
| }); | |
| await github.rest.pulls.update({ | |
| owner, repo, pull_number: pr.number, state: "closed" | |
| }); | |
| await deleteSameRepoBranch(pr, "hourly-close"); | |
| } | |
| // Hourly sweep: try to delete branches for recently-closed PRs too. | |
| // This is a safety net for cases where the on-close workflow didn't run | |
| // or branch deletion failed transiently. | |
| const maxAgeHoursForClosedSweep = 48; | |
| const closedPulls = await github.paginate(github.rest.pulls.list, { | |
| owner, | |
| repo, | |
| state: "closed", | |
| per_page: 100, | |
| sort: "updated", | |
| direction: "desc" | |
| }); | |
| for (const pr of closedPulls) { | |
| const updated = new Date(pr.updated_at); | |
| const ageHours = (now - updated) / (1000 * 60 * 60); | |
| if (ageHours > maxAgeHoursForClosedSweep) break; | |
| await deleteSameRepoBranch(pr, "hourly-sweep"); | |
| } |