stale-blocked: Make it less aggressive

This commit is contained in:
bbhtt
2026-03-19 01:04:45 +05:30
parent 53be6607f5
commit 4045950042
+233 -129
View File
@@ -1,4 +1,35 @@
#
# This workflow aims to reduce the number of PRs that fail basic
# requirements by automatically flagging and closing those
# that remain unaddressed for an extended period. Unlike the stale
# action, this works based on the label application date since the
# blocking labels are added manually. It ignores push events and updates
# intentionally as blocking labels are supposed to be manually
# removed on review once the PR passes basic requirements and neither
# push events nor updates signal that the PR passes the basic
# requirements again.
#
# This workflow processes open PRs that -
#
# 1. Aren't drafts or exempted through EXEMPT_LABEL label, AND
# 2. Have one or more of LABEL_BLOCKED, LABEL_CHECKS, or LABEL_STALE
#
# It tracks how long blocking labels have been applied, then -
#
# 1. If the PR no longer has any blocking labels (LABEL_BLOCKED or
# LABEL_CHECKS), the LABEL_STALE label is removed.
# 2. If a PR is blocked (LABEL_BLOCKED or LABEL_CHECKS labels) for at
# least COMMENT_AFTER_DAYS days, a warning comment is posted notifying
# that it will be closed in CLOSE_AFTER_COMMENT_DAYS more days and
# the LABEL_STALE label is applied.
# 3. If a PR is both stale (LABEL_STALE label) and still blocked
# (LABEL_BLOCKED or LABEL_CHECKS labels), it is closed after
# CLOSE_AFTER_COMMENT_DAYS days from when the LABEL_STALE label was
# applied.
#
name: "Close stale blocked PRs"
on:
schedule:
- cron: "0 0 * * 1"
@@ -8,10 +39,12 @@ on:
description: "Dry run"
type: boolean
default: false
jobs:
close-blocked:
permissions:
pull-requests: write
issues: write
runs-on: ubuntu-latest
steps:
- name: Close stale blocked PRs
@@ -19,154 +52,225 @@ jobs:
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
env:
DRY_RUN: ${{ inputs.dry_run || 'false' }}
DAYS_THRESHOLD: "14"
COMMENT_AFTER_DAYS: "14"
CLOSE_AFTER_COMMENT_DAYS: "7"
LABEL_BLOCKED: "blocked"
LABEL_CHECKS: "pr-check-blocked"
COMMENT_BLOCKED: |-
This pull request has been marked as blocked for {days} days
and is not ready for inclusion. It is being automatically closed.
COMMENT_CHECKS: |-
This pull request is failing checks for {days} days
and is not ready for inclusion. It is being automatically closed.
COMMENT_BOTH: |-
This pull request has been marked as blocked and is failing
checks for {days} days. It is not ready for inclusion. It is being
automatically closed.
EXEMPT_LABEL: "leave-open"
CLOSE_LABEL: "Stale"
LABEL_STALE: "stale-blocked"
COMMENT: |-
<!-- stale-blocked-bot -->
This pull request has been marked as blocked for {days} days
and is not ready for inclusion. It will be closed in {grace_days}
days unless the issues are fixed and the {label} label is removed.
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const labelBlocked = process.env.LABEL_BLOCKED || "blocked";
const labelChecks = process.env.LABEL_CHECKS || "pr-check-blocked";
const labelStale = process.env.LABEL_STALE || "Stale";
const LABEL_BLOCKED = process.env.LABEL_BLOCKED || "blocked";
const LABEL_CHECKS = process.env.LABEL_CHECKS || "pr-check-blocked";
const LABEL_STALE = process.env.LABEL_STALE || "stale-blocked";
const EXEMPT_LABEL = process.env.EXEMPT_LABEL || "leave-open";
const daysThreshold = parseInt(process.env.DAYS_THRESHOLD || "14", 10);
const exemptLabel = process.env.EXEMPT_LABEL || "leave-open";
const dryRun = process.env.DRY_RUN === "true";
const COMMENT_AFTER_DAYS = parseInt(process.env.COMMENT_AFTER_DAYS || "14", 10);
const CLOSE_AFTER_DAYS = parseInt(
process.env.CLOSE_AFTER_COMMENT_DAYS || "7",
10
);
if (daysThreshold < 2) throw new Error(`DAYS_THRESHOLD must be >= 2, got ${daysThreshold}`);
const DRY_RUN = process.env.DRY_RUN === "true";
const COMMENT_TEMPLATE = process.env.COMMENT?.trim();
const STALE_MARKER = "<!-- stale-blocked-bot -->";
const MS_PER_DAY = 1000 * 60 * 60 * 24;
const now = new Date();
if (!COMMENT_TEMPLATE) throw new Error("COMMENT env var is required");
const commentTemplates = {
blocked: process.env.COMMENT_BLOCKED,
checks: process.env.COMMENT_CHECKS,
both: process.env.COMMENT_BOTH,
};
const { owner, repo } = context.repo;
const now = Date.now();
for (const [key, val] of Object.entries(commentTemplates)) {
if (!val?.trim()) throw new Error(`Comment template "${key}" is empty or missing`);
function act(description, fn) {
if (DRY_RUN) {
console.log(`[DRY RUN] ${description}`);
return Promise.resolve();
}
console.log(description);
return fn();
}
function latestUnremovedLabelDate(events, label) {
let applied = null;
for (const e of events) {
if (e.event === "labeled" && e.label?.name === label)
applied = new Date(e.created_at).getTime();
if (e.event === "unlabeled" && e.label?.name === label) applied = null;
}
return applied;
}
function daysSince(ms) {
return (now - ms) / MS_PER_DAY;
}
try {
const rate = await github.rest.rateLimit.get();
console.log(`Rate limit remaining: ${rate.data.rate.remaining}`);
} catch (e) {
console.log("Could not fetch rate limit");
}
const prs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
owner,
repo,
state: "open",
per_page: 100
per_page: 100,
});
const blockedCount = prs.filter(pr =>
pr.labels.some(l => l.name === labelBlocked)
).length;
let globalThreshold = daysThreshold;
if (blockedCount > 10) {
globalThreshold = Math.floor(daysThreshold / 2);
}
for (const pr of prs) {
const prLabels = pr.labels.map(l => l.name);
const hasBlocked = prLabels.includes(labelBlocked);
const hasChecks = prLabels.includes(labelChecks);
if (!hasBlocked && !hasChecks) continue;
if (pr.draft) continue;
if (prLabels.includes(exemptLabel)) continue;
const effectiveThreshold = hasChecks
? Math.floor(globalThreshold / 2)
: globalThreshold;
const commentTemplate = hasBlocked && hasChecks
? commentTemplates.both
: hasChecks
? commentTemplates.checks
: commentTemplates.blocked;
try {
const events = await github.paginate(
github.rest.issues.listEventsForTimeline,
{
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
}
);
const relevantLabels = [];
if (hasBlocked) relevantLabels.push(labelBlocked);
if (hasChecks) relevantLabels.push(labelChecks);
const labelEvents = events
.filter(e =>
e.event === "labeled" &&
relevantLabels.includes(e.label?.name)
)
.map(e => new Date(e.created_at));
if (labelEvents.length === 0) continue;
const appliedDate = new Date(
Math.min(...labelEvents.map(d => d.getTime()))
);
const diffDays = (now - appliedDate) / (1000 * 60 * 60 * 24);
if (diffDays >= effectiveThreshold) {
const days = Math.floor(diffDays);
let reason;
if (hasBlocked && hasChecks) {
reason = "marked as blocked and failing checks";
} else if (hasChecks) {
reason = "failing checks";
} else {
reason = "marked as blocked";
}
const msg = `Closing PR ${pr.html_url} because it has been ${reason} for ${days} days`;
if (dryRun) {
console.log(`[DRY RUN] ${msg}`);
} else {
console.log(msg);
try {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: commentTemplate.replace("{days}", Math.floor(diffDays))
});
} catch (err) {
console.warn(`Failed to comment on PR ${pr.html_url}: ${err.message}`);
}
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: "closed"
});
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [labelStale]
});
}
}
await processPR(pr);
} catch (err) {
console.error(`Error processing PR ${pr.html_url}: ${err.message}`);
console.error(
`Error processing PR #${pr.number} (${pr.html_url}): ${err.message}`
);
}
}
async function processPR(pr) {
const labels = new Set(pr.labels.map((l) => l.name));
const has = (label) => labels.has(label);
const isBlocked = has(LABEL_BLOCKED);
const isChecks = has(LABEL_CHECKS);
const isStale = has(LABEL_STALE);
if (!isBlocked && !isChecks && !isStale) return;
if (pr.draft) return;
if (has(EXEMPT_LABEL)) return;
let events;
try {
events = await github.paginate(github.rest.issues.listEventsForTimeline, {
owner,
repo,
issue_number: pr.number,
});
} catch (e) {
console.warn(`Failed to fetch timeline for #${pr.number}: ${e.message}`);
return;
}
if (isStale && !isBlocked && !isChecks) {
const { data: fresh } = await github.rest.pulls.get({
owner,
repo,
pull_number: pr.number,
});
const freshLabels = new Set(fresh.labels.map((l) => l.name));
if (freshLabels.has(LABEL_BLOCKED) || freshLabels.has(LABEL_CHECKS)) return;
await act(
`Removing stale label from #${pr.number} (no longer blocked)`,
() =>
github.rest.issues.removeLabel({
owner,
repo,
issue_number: pr.number,
name: LABEL_STALE,
})
);
return;
}
if (isStale && (isBlocked || isChecks)) {
const staleApplied = latestUnremovedLabelDate(events, LABEL_STALE);
if (staleApplied === null) return;
const diff = daysSince(staleApplied);
console.log(`[INFO] #${pr.number}: stale for ${Math.floor(diff)} days`);
if (diff < CLOSE_AFTER_DAYS) return;
const { data: fresh } = await github.rest.pulls.get({
owner,
repo,
pull_number: pr.number,
});
if (!fresh.labels.some((l) => l.name === LABEL_STALE)) return;
await act(
`Closing PR #${pr.number} — stale and blocked for ${Math.floor(
diff
)} days`,
() =>
github.rest.pulls.update({
owner,
repo,
pull_number: pr.number,
state: "closed",
})
);
return;
}
const blockAppliedDates = [
isBlocked ? latestUnremovedLabelDate(events, LABEL_BLOCKED) : null,
isChecks ? latestUnremovedLabelDate(events, LABEL_CHECKS) : null,
].filter(Number.isFinite);
if (blockAppliedDates.length === 0) return;
const appliedDate = Math.min(...blockAppliedDates);
const diffDays = daysSince(appliedDate);
console.log(`[INFO] #${pr.number}: blocked for ${Math.floor(diffDays)} days`);
if (diffDays < COMMENT_AFTER_DAYS) return;
const labelText = [
isBlocked ? LABEL_BLOCKED : null,
isChecks ? LABEL_CHECKS : null,
]
.filter(Boolean)
.join(" / ");
const body = COMMENT_TEMPLATE.replaceAll("{days}", Math.floor(diffDays))
.replaceAll("{grace_days}", CLOSE_AFTER_DAYS)
.replaceAll("{label}", labelText);
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: pr.number,
});
const alreadyCommented = comments.some((c) => c.body?.includes(STALE_MARKER));
if (!alreadyCommented) {
await act(
`Commenting on PR #${pr.number} (blocked ${Math.floor(
diffDays
)} days) and marking stale`,
async () => {
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body,
});
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pr.number,
labels: [LABEL_STALE],
});
}
);
} else if (!isStale) {
await act(
`Re-applying stale label to PR #${pr.number} (comment already present)`,
() =>
github.rest.issues.addLabels({
owner,
repo,
issue_number: pr.number,
labels: [LABEL_STALE],
})
);
}
}