Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 119 additions & 45 deletions .github/workflows/close-failed-prs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,57 +24,131 @@ jobs:
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - cutoffDays);

const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100
});

for (const pr of prs) {
const updated = new Date(pr.updated_at);
if (updated > cutoff) continue;

const commits = await github.paginate(github.rest.pulls.listCommits, {
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,
pull_number: pr.number,
state: 'open',
sort: 'updated',
direction: 'asc',
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;
});
console.log(`Found ${prs.length} open PRs to check`);

const { data: checks } = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: pr.head.sha
});
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`);
}

const hasFailed = checks.check_runs.some(c => c.conclusion === 'failure');
const allCompleted = checks.check_runs.every(c => c.status === 'completed');

if (meaningfulCommits.length === 0 && hasFailed && 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 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'
});
} catch (prError) {
console.error(`❌ Error processing PR #${pr.number}: ${prError.message}`);
continue;
}
}

} catch (error) {
console.error(`❌ Fatal error: ${error.message}`);
throw error;
}