Close stale PRs with failed workflows #7
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: Close stale PRs with failed workflows | |
| on: | |
| schedule: | |
| - cron: '0 3 * * *' # runs daily at 03:00 UTC | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| issues: write | |
| pull-requests: write | |
| jobs: | |
| close-stale: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Close stale PRs | |
| uses: actions/github-script@v8 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const mainBranches = ['main', 'master']; | |
| const cutoffDays = 14; | |
| const cutoff = new Date(); | |
| cutoff.setDate(cutoff.getDate() - cutoffDays); | |
| console.log(`Checking PRs older than: ${cutoff.toISOString()}`); | |
| try { | |
| const { data: prs } = await github.rest.pulls.list({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| sort: 'updated', | |
| direction: 'asc', | |
| per_page: 100 | |
| }); | |
| console.log(`Found ${prs.length} open PRs to check`); | |
| for (const pr of prs) { | |
| try { | |
| const updated = new Date(pr.updated_at); | |
| if (updated > cutoff) { | |
| console.log(`⏩ Skipping PR #${pr.number} - updated recently`); | |
| continue; | |
| } | |
| console.log(`🔍 Checking PR #${pr.number}: "${pr.title}"`); | |
| // Get commits | |
| const commits = await github.paginate(github.rest.pulls.listCommits, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr.number, | |
| per_page: 100 | |
| }); | |
| const meaningfulCommits = commits.filter(c => { | |
| const msg = c.commit.message.toLowerCase(); | |
| const isMergeFromMain = mainBranches.some(branch => | |
| msg.startsWith(`merge branch '${branch}'`) || | |
| msg.includes(`merge remote-tracking branch '${branch}'`) | |
| ); | |
| return !isMergeFromMain; | |
| }); | |
| // Get checks with error handling | |
| let hasFailedChecks = false; | |
| let allChecksCompleted = false; | |
| let hasChecks = false; | |
| try { | |
| const { data: checks } = await github.rest.checks.listForRef({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: pr.head.sha | |
| }); | |
| hasChecks = checks.check_runs.length > 0; | |
| hasFailedChecks = checks.check_runs.some(c => c.conclusion === 'failure'); | |
| allChecksCompleted = checks.check_runs.every(c => | |
| c.status === 'completed' || c.status === 'skipped' | |
| ); | |
| } catch (error) { | |
| console.log(`⚠️ Could not fetch checks for PR #${pr.number}: ${error.message}`); | |
| } | |
| // Get workflow runs with error handling | |
| let hasFailedWorkflows = false; | |
| let allWorkflowsCompleted = false; | |
| let hasWorkflows = false; | |
| try { | |
| const { data: runs } = await github.rest.actions.listWorkflowRuns({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| head_sha: pr.head.sha, | |
| per_page: 50 | |
| }); | |
| hasWorkflows = runs.workflow_runs.length > 0; | |
| hasFailedWorkflows = runs.workflow_runs.some(r => r.conclusion === 'failure'); | |
| allWorkflowsCompleted = runs.workflow_runs.every(r => | |
| ['completed', 'skipped', 'cancelled'].includes(r.status) | |
| ); | |
| console.log(`PR #${pr.number}: ${runs.workflow_runs.length} workflow runs found`); | |
| } catch (error) { | |
| console.log(`⚠️ Could not fetch workflow runs for PR #${pr.number}: ${error.message}`); | |
| } | |
| console.log(`PR #${pr.number}: ${meaningfulCommits.length} meaningful commits`); | |
| console.log(`Checks - has: ${hasChecks}, failed: ${hasFailedChecks}, completed: ${allChecksCompleted}`); | |
| console.log(`Workflows - has: ${hasWorkflows}, failed: ${hasFailedWorkflows}, completed: ${allWorkflowsCompleted}`); | |
| // Combine conditions - only consider if we actually have checks/workflows | |
| const hasAnyFailure = (hasChecks && hasFailedChecks) || (hasWorkflows && hasFailedWorkflows); | |
| const allCompleted = (!hasChecks || allChecksCompleted) && (!hasWorkflows || allWorkflowsCompleted); | |
| if (meaningfulCommits.length === 0 && hasAnyFailure && allCompleted) { | |
| console.log(`✅ Closing PR #${pr.number} (${pr.title})`); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr.number, | |
| body: `This pull request has been automatically closed because its workflows or checks failed and it has been inactive for more than ${cutoffDays} days. Please fix the workflows and reopen if you'd like to continue. Merging from main/master alone does not count as activity.` | |
| }); | |
| await github.rest.pulls.update({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr.number, | |
| state: 'closed' | |
| }); | |
| console.log(`✅ Successfully closed PR #${pr.number}`); | |
| } else { | |
| console.log(`⏩ Not closing PR #${pr.number} - conditions not met`); | |
| } | |
| } catch (prError) { | |
| console.error(`❌ Error processing PR #${pr.number}: ${prError.message}`); | |
| continue; | |
| } | |
| } | |
| } catch (error) { | |
| console.error(`❌ Fatal error: ${error.message}`); | |
| throw error; | |
| } |