mirror of
https://github.com/stan220/flathub.git
synced 2026-09-08 16:28:52 +00:00
stale-blocked: Make it less aggressive
This commit is contained in:
+233
-129
@@ -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],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user