Auto-close PRs without linked issue #106
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: 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\`.`, | |
| }); | |
| } |