PR Stale Check #47
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 Stale Check | |
| on: | |
| schedule: | |
| - cron: "0 7 * * *" | |
| workflow_dispatch: | |
| permissions: | |
| pull-requests: write | |
| issues: write | |
| checks: read | |
| statuses: read | |
| jobs: | |
| stale-check: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Label stale PRs | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const STALE_LABEL = 'stale'; | |
| const HIGH_LABEL = 'priority: high'; | |
| const HIGH_DAYS = 3; | |
| const DEFAULT_DAYS = 7; | |
| const CLOSE_AFTER_STALE_DAYS = 14; | |
| const now = Date.now(); | |
| const msPerDay = 86400000; | |
| const prs = await github.rest.pulls.list({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| }); | |
| for (const pr of prs.data) { | |
| if (pr.draft) continue; | |
| const labels = pr.labels.map(l => l.name); | |
| const isHigh = labels.includes(HIGH_LABEL); | |
| // Also check linked issue labels | |
| if (!isHigh) { | |
| const { data: events } = await github.rest.issues.listEventsForTimeline({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr.number, | |
| per_page: 100, | |
| }); | |
| for (const evt of events) { | |
| if (evt.event === 'cross-referenced' && evt.source?.issue) { | |
| const issue = evt.source.issue; | |
| if (issue.labels?.some(l => l.name === HIGH_LABEL)) { | |
| labels.push(HIGH_LABEL); | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| const staleDays = labels.includes(HIGH_LABEL) ? HIGH_DAYS : DEFAULT_DAYS; | |
| // Find the last event requiring author action: | |
| // either a "changes requested" review or a failing CI check | |
| const reviews = await github.rest.pulls.listReviews({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr.number, | |
| }); | |
| let actionNeededSince = null; | |
| for (const review of reviews.data) { | |
| if (review.state === 'CHANGES_REQUESTED') { | |
| const t = new Date(review.submitted_at).getTime(); | |
| if (!actionNeededSince || t > actionNeededSince) { | |
| actionNeededSince = t; | |
| } | |
| } | |
| } | |
| // Check for failing CI on the head commit | |
| const { data: checkRuns } = await github.rest.checks.listForRef({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: pr.head.sha, | |
| }); | |
| for (const run of checkRuns.check_runs) { | |
| if (run.conclusion === 'failure' && run.completed_at) { | |
| const t = new Date(run.completed_at).getTime(); | |
| if (!actionNeededSince || t > actionNeededSince) { | |
| actionNeededSince = t; | |
| } | |
| } | |
| } | |
| if (!actionNeededSince) { | |
| if (labels.includes(STALE_LABEL)) { | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr.number, | |
| name: STALE_LABEL, | |
| }).catch(() => {}); | |
| } | |
| continue; | |
| } | |
| // Check if the author responded after the last review | |
| const authorLogin = pr.user.login; | |
| const comments = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr.number, | |
| }); | |
| let authorRespondedAfter = false; | |
| for (const c of comments.data) { | |
| if (c.user.login === authorLogin && new Date(c.created_at).getTime() > actionNeededSince) { | |
| authorRespondedAfter = true; | |
| break; | |
| } | |
| } | |
| // Also check if author pushed after the review | |
| if (!authorRespondedAfter) { | |
| const commits = await github.rest.pulls.listCommits({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr.number, | |
| }); | |
| for (const c of commits.data) { | |
| const commitTime = new Date(c.commit.committer.date).getTime(); | |
| if (c.author?.login === authorLogin && commitTime > actionNeededSince) { | |
| authorRespondedAfter = true; | |
| break; | |
| } | |
| } | |
| } | |
| if (authorRespondedAfter) { | |
| if (labels.includes(STALE_LABEL)) { | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr.number, | |
| name: STALE_LABEL, | |
| }).catch(() => {}); | |
| core.info(`PR #${pr.number}: author responded, removed stale label`); | |
| } | |
| continue; | |
| } | |
| const daysSince = Math.floor((now - actionNeededSince) / msPerDay); | |
| core.info(`PR #${pr.number}: ${daysSince}d since changes requested (SLA: ${staleDays}d)`); | |
| if (daysSince >= staleDays && !labels.includes(STALE_LABEL)) { | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr.number, | |
| labels: [STALE_LABEL], | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr.number, | |
| body: `This PR has had no author follow-up for ${daysSince} days since changes were requested. Marking as **stale**. Please push updates or comment if you're still working on this.\n\nThis PR will be closed automatically if there is no activity within ${CLOSE_AFTER_STALE_DAYS} days.`, | |
| }); | |
| core.info(`PR #${pr.number}: marked stale (${daysSince}d)`); | |
| } else if (labels.includes(STALE_LABEL)) { | |
| // Find when the stale label was added | |
| const events = await github.rest.issues.listEvents({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr.number, | |
| }); | |
| let staledAt = null; | |
| for (const evt of events.data) { | |
| if (evt.event === 'labeled' && evt.label?.name === STALE_LABEL) { | |
| staledAt = new Date(evt.created_at).getTime(); | |
| } | |
| } | |
| if (staledAt) { | |
| const staleDaysSince = Math.floor((now - staledAt) / msPerDay); | |
| if (staleDaysSince >= CLOSE_AFTER_STALE_DAYS) { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr.number, | |
| body: `Closing this PR after ${CLOSE_AFTER_STALE_DAYS} days with no activity since being marked stale. Feel free to reopen if you'd like to continue working on this.`, | |
| }); | |
| await github.rest.pulls.update({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr.number, | |
| state: 'closed', | |
| }); | |
| core.info(`PR #${pr.number}: auto-closed (${staleDaysSince}d stale)`); | |
| } | |
| } | |
| } | |
| } |