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 date = new Date(c.commit.committer.date); const isMergeFromMain = mainBranches.some(branch => msg.startsWith(`merge branch '${branch}'`) || msg.includes(`merge remote-tracking branch '${branch}'`) ); return !isMergeFromMain && date > cutoff; }); // 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; }