Skip to content

Auto-close PRs without linked issue #106

Auto-close PRs without linked issue

Auto-close PRs without linked issue #106

name: Auto-close PRs without linked issue
on:
schedule:
- cron: '0 0 * * *' # daily at midnight UTC
workflow_dispatch:
jobs:
auto-close:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Close PRs missing linked issue for over 4 days
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const fourDaysAgo = new Date(Date.now() - 4 * 24 * 60 * 60 * 1000);
const prs = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: 'open',
labels: 'missing-issue',
});
for (const pr of prs) {
if (!pr.pull_request) continue;
const isExempt =
pr.labels.some(l => l.name === 'hotfix') ||
pr.user.login === 'dependabot[bot]' ||
pr.labels.some(l => l.name === 'dependencies');
if (isExempt) continue;
const isCollaborator = await github.rest.repos.checkCollaborator({
owner,
repo,
username: pr.user.login,
}).then(() => true).catch(() => false);
if (isCollaborator) continue;
// Find when the missing-issue label was applied
const events = await github.paginate(github.rest.issues.listEventsForTimeline, {
owner,
repo,
issue_number: pr.number,
});
const labelEvent = events
.filter(e => e.event === 'labeled' && e.label?.name === 'missing-issue')
.pop(); // most recent application
if (!labelEvent) continue;
if (new Date(labelEvent.created_at) > fourDaysAgo) continue;
// Re-fetch the PR's current state to guard against race conditions:
// the label may have been removed between the initial paginated query
// and this point in the loop.
const currentPr = await github.rest.pulls.get({
owner,
repo,
pull_number: pr.number,
});
if (currentPr.data.state !== 'open') continue;
const currentLabels = currentPr.data.labels.map(l => l.name);
if (!currentLabels.includes('missing-issue')) continue;
const result = await github.graphql(`
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
closingIssuesReferences(first: 1) {
totalCount
}
}
}
}
`, { owner, repo, number: pr.number });
if (result.repository.pullRequest.closingIssuesReferences.totalCount > 0) {
// An issue was linked without an event that re-runs the check
// workflow (e.g. via the Development sidebar), so clean up here.
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: pr.number,
name: 'missing-issue',
});
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: pr.number,
per_page: 100,
});
for (const comment of comments) {
if (comment.user.type === 'Bot' && comment.body.includes('To link an issue, edit the PR description')) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id,
});
}
}
continue;
}
await github.rest.pulls.update({
owner,
repo,
pull_number: pr.number,
state: 'closed',
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body: `This PR has been automatically closed because no linked issue was provided within 4 days. Feel free to reopen it after linking an issue, just edit the PR description and add a line like \`Closes #123\`.`,
});
}