Skip to content

PR Stale Check

PR Stale Check #51

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)`);
}
}
}
}