1+ name : Close stale PRs with failed workflows
2+
3+ on :
4+ schedule :
5+ - cron : ' 0 3 * * *' # runs daily at 03:00 UTC
6+ workflow_dispatch :
7+
8+ permissions :
9+ contents : read
10+ issues : write
11+ pull-requests : write
12+
13+ jobs :
14+ close-stale :
15+ runs-on : ubuntu-latest
16+ steps :
17+ - name : Close stale PRs
18+ uses : actions/github-script@v7
19+ with :
20+ github-token : ${{ secrets.GITHUB_TOKEN }}
21+ script : |
22+ const mainBranches = ['main', 'master'];
23+ const cutoffDays = 14;
24+ const cutoff = new Date();
25+ cutoff.setDate(cutoff.getDate() - cutoffDays);
26+
27+ console.log(`Checking PRs older than: ${cutoff.toISOString()}`);
28+
29+ try {
30+ const { data: prs } = await github.rest.pulls.list({
31+ owner: context.repo.owner,
32+ repo: context.repo.repo,
33+ state: 'open',
34+ sort: 'updated',
35+ direction: 'asc',
36+ per_page: 100
37+ });
38+
39+ console.log(`Found ${prs.length} open PRs to check`);
40+
41+ for (const pr of prs) {
42+ try {
43+ const updated = new Date(pr.updated_at);
44+
45+ if (updated > cutoff) {
46+ console.log(`⏩ Skipping PR #${pr.number} - updated recently`);
47+ continue;
48+ }
49+
50+ console.log(`🔍 Checking PR #${pr.number}: "${pr.title}"`);
51+
52+ // Get commits
53+ const commits = await github.paginate(github.rest.pulls.listCommits, {
54+ owner: context.repo.owner,
55+ repo: context.repo.repo,
56+ pull_number: pr.number,
57+ per_page: 100
58+ });
59+
60+ const meaningfulCommits = commits.filter(c => {
61+ const msg = c.commit.message.toLowerCase();
62+ const isMergeFromMain = mainBranches.some(branch =>
63+ msg.startsWith(`merge branch '${branch}'`) ||
64+ msg.includes(`merge remote-tracking branch '${branch}'`)
65+ );
66+ return !isMergeFromMain;
67+ });
68+
69+ // Get checks with error handling
70+ let hasFailedChecks = false;
71+ let allChecksCompleted = false;
72+ let hasChecks = false;
73+
74+ try {
75+ const { data: checks } = await github.rest.checks.listForRef({
76+ owner: context.repo.owner,
77+ repo: context.repo.repo,
78+ ref: pr.head.sha
79+ });
80+
81+ hasChecks = checks.check_runs.length > 0;
82+ hasFailedChecks = checks.check_runs.some(c => c.conclusion === 'failure');
83+ allChecksCompleted = checks.check_runs.every(c =>
84+ c.status === 'completed' || c.status === 'skipped'
85+ );
86+ } catch (error) {
87+ console.log(`⚠️ Could not fetch checks for PR #${pr.number}: ${error.message}`);
88+ }
89+
90+ // Get workflow runs with error handling
91+ let hasFailedWorkflows = false;
92+ let allWorkflowsCompleted = false;
93+ let hasWorkflows = false;
94+
95+ try {
96+ const { data: runs } = await github.rest.actions.listWorkflowRuns({
97+ owner: context.repo.owner,
98+ repo: context.repo.repo,
99+ head_sha: pr.head.sha,
100+ per_page: 50
101+ });
102+
103+ hasWorkflows = runs.workflow_runs.length > 0;
104+ hasFailedWorkflows = runs.workflow_runs.some(r => r.conclusion === 'failure');
105+ allWorkflowsCompleted = runs.workflow_runs.every(r =>
106+ ['completed', 'skipped', 'cancelled'].includes(r.status)
107+ );
108+
109+ console.log(`PR #${pr.number}: ${runs.workflow_runs.length} workflow runs found`);
110+
111+ } catch (error) {
112+ console.log(`⚠️ Could not fetch workflow runs for PR #${pr.number}: ${error.message}`);
113+ }
114+
115+ console.log(`PR #${pr.number}: ${meaningfulCommits.length} meaningful commits`);
116+ console.log(`Checks - has: ${hasChecks}, failed: ${hasFailedChecks}, completed: ${allChecksCompleted}`);
117+ console.log(`Workflows - has: ${hasWorkflows}, failed: ${hasFailedWorkflows}, completed: ${allWorkflowsCompleted}`);
118+
119+ // Combine conditions - only consider if we actually have checks/workflows
120+ const hasAnyFailure = (hasChecks && hasFailedChecks) || (hasWorkflows && hasFailedWorkflows);
121+ const allCompleted = (!hasChecks || allChecksCompleted) && (!hasWorkflows || allWorkflowsCompleted);
122+
123+ if (meaningfulCommits.length === 0 && hasAnyFailure && allCompleted) {
124+ console.log(`✅ Closing PR #${pr.number} (${pr.title})`);
125+
126+ await github.rest.issues.createComment({
127+ owner: context.repo.owner,
128+ repo: context.repo.repo,
129+ issue_number: pr.number,
130+ 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.`
131+ });
132+
133+ await github.rest.pulls.update({
134+ owner: context.repo.owner,
135+ repo: context.repo.repo,
136+ pull_number: pr.number,
137+ state: 'closed'
138+ });
139+
140+ console.log(`✅ Successfully closed PR #${pr.number}`);
141+ } else {
142+ console.log(`⏩ Not closing PR #${pr.number} - conditions not met`);
143+ }
144+
145+ } catch (prError) {
146+ console.error(`❌ Error processing PR #${pr.number}: ${prError.message}`);
147+ continue;
148+ }
149+ }
150+
151+ } catch (error) {
152+ console.error(`❌ Fatal error: ${error.message}`);
153+ throw error;
154+ }
0 commit comments