Skip to content

PR Inactivity Closer #7

PR Inactivity Closer

PR Inactivity Closer #7

name: PR Inactivity Closer
on:
schedule:
- cron: '0 0 * * *' # Runs daily at midnight UTC
workflow_dispatch: # Allows manual triggering from the Actions tab
permissions:
issues: write
pull-requests: write
jobs:
close-inactive-prs:
name: Close Inactive PRs
runs-on: ubuntu-latest
steps:
- name: Process Inactive Pull Requests
uses: actions/github-script@v7
with:
script: |
const label = 'inactive';
const closeAfterDays = 5;
const warnAfterDays = 3;
const now = new Date();
core.info(`Checking PRs for inactivity. Warn after ${warnAfterDays} days, close after ${closeAfterDays} days.`);
// Ensure the inactivity label exists in the repository
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
});
} catch {
core.info(`Creating label '${label}'...`);
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
color: '6b7280',
description: 'This PR was closed due to inactivity.',
});
}
// Fetch all open PRs
const { data: openPRs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
core.info(`Scanning ${openPRs.length} open pull requests...`);
const skipLabels = ['wip', 'blocked', 'do-not-merge'];
for (const pr of openPRs) {
// Draft PRs are intentionally long-lived
if (pr.draft) {
core.info(`PR #${pr.number} is a draft. Skipping.`);
continue;
}
// Maintainer and collaborator PRs should not be auto-closed
if (['COLLABORATOR', 'MEMBER', 'OWNER'].includes(pr.author_association)) {
core.info(`PR #${pr.number} is from a maintainer (@${pr.user.login}). Skipping.`);
continue;
}
// PRs carrying exclusion labels
const prLabelNames = (pr.labels || []).map(l => l.name.toLowerCase());
if (prLabelNames.some(l => skipLabels.includes(l))) {
core.info(`PR #${pr.number} has a wip/blocked/do-not-merge label. Skipping.`);
continue;
}
const updatedAt = new Date(pr.updated_at);
const daysSinceUpdate = Math.floor((now.getTime() - updatedAt.getTime()) / (1000 * 60 * 60 * 24));
if (daysSinceUpdate >= closeAfterDays) {
core.info(`PR #${pr.number} (@${pr.user.login}) is inactive. Last updated: ${pr.updated_at}. Closing...`);
// 1. Add inactivity label
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [label],
});
// 2. Post explanatory comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: `🤖 Hey @${pr.user.login}, this pull request has been automatically closed because it has been inactive for ${closeAfterDays} days.\n\nIf you are still interested in fixing the issue and contributing, please feel free to pull the latest changes, resolve any conflicts, and reopen this PR at any time! We'd love to review your work. Thank you! ❤️`,
});
// 3. Close the PR
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: 'closed',
});
core.info(`Successfully closed PR #${pr.number}.`);
} else if (daysSinceUpdate >= warnAfterDays) {
// Avoid posting multiple warnings on consecutive runs
const { data: existingComments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
per_page: 10,
});
if (existingComments.some(c => c.body && c.body.includes('<!-- inactivity-warning -->'))) {
core.info(`PR #${pr.number} already has a warning. Skipping.`);
continue;
}
core.info(`PR #${pr.number} (@${pr.user.login}) has been inactive for ${daysSinceUpdate} days. Posting warning...`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: `⚠️ Hey @${pr.user.login}, this pull request has been inactive for ${daysSinceUpdate} days. It will be automatically closed in ${closeAfterDays - daysSinceUpdate} days if no further activity occurs.\n\nIf you are still working on this, please push your latest changes or leave a comment to keep it active.\n\n<!-- inactivity-warning -->`,
});
core.info(`Warning posted on PR #${pr.number}.`);
} else {
core.info(`PR #${pr.number} is active. Last updated: ${pr.updated_at}. Skipping.`);
}
}