Files
Scot BreitenfeldandH. Joe Lee a7eeb4fa9b Add stale PR policy with assignee alerts. (#6463)
* Add stale PR/issue policy with assignee alerts instead of auto-close

Ready PRs/issues use actions/stale to label inactivity (30/60 days).
Draft PRs get a longer 90-day window and only reset on an explicit
"still working on this" comment, since pushes/CI activity alone
shouldn't make an abandoned draft look fresh. Nothing is auto-closed:
once a staleness label has persisted past its alert threshold, a
custom script pings the assignee (falling back to requested
reviewers, then the author) to decide whether to keep it open or
close it.

* Scope stale policy to PRs only, not issues

Issue staleness is disabled (days-before-issue-stale: -1) and the
alert script now skips any non-PR item defensively, since this
workflow is meant to address PRs sitting unmerged/unreviewed, not
issue triage.

* Draft stale window 60 days (was 90); drop dead pull_request branch

alert-stale.js's pickAlertTargets is now only ever called with PR
items (filtered upstream in runAlertStale), so the pull_request
check around the requested-reviewers lookup was dead code.

* Set persist-credentials: false on checkout steps

Fixes two zizmor notes: these checkouts only need to read local
script files for github-script's require(), so there's no reason
to persist the GITHUB_TOKEN in git config afterward.

* Use a keep-alive checkbox instead of a magic comment phrase for drafts

Checking a box in the bot's own stale-notice comment is more
discoverable than requiring an exact phrase, and the live checkbox
state can be read straight off that comment's current body on each
run instead of scanning new comments for a regex match.

---------

Co-authored-by: H. Joe Lee <hyoklee@hdfgroup.org>
2026-06-19 11:32:43 -05:00

90 lines
3.5 KiB
JavaScript

// PR-only: once a PR has carried a staleness label past its alert threshold,
// ping a human to decide instead of auto-closing -- assignees first, then
// requested reviewers, then the author. Alerts only once per staleness
// episode: the "needs-decision" label prevents re-pinging on every
// scheduled run until a maintainer clears it.
const ALERT_LABEL = "needs-decision";
const ALERT_DAYS_BY_STALE_LABEL = {
stale: 14,
"draft-stale": 30,
};
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const daysSince = (isoDate) => (Date.now() - new Date(isoDate).getTime()) / MS_PER_DAY;
async function ensureAlertLabel(github, owner, repo) {
try {
await github.rest.issues.getLabel({ owner, repo, name: ALERT_LABEL });
} catch (err) {
if (err.status !== 404) throw err;
await github.rest.issues.createLabel({
owner,
repo,
name: ALERT_LABEL,
color: "d93f0b",
description: "Stale past the alert threshold -- needs a maintainer decision to keep open or close",
});
}
}
async function findLabelAddedAt(github, owner, repo, issue_number, labelName) {
const events = await github.paginate(github.rest.issues.listEvents, { owner, repo, issue_number, per_page: 100 });
const labeledEvents = events.filter((e) => e.event === "labeled" && e.label?.name === labelName);
return labeledEvents.length ? labeledEvents[labeledEvents.length - 1].created_at : null;
}
async function pickAlertTargets(github, owner, repo, item) {
if (item.assignees?.length) return item.assignees.map((u) => u.login);
// Caller guarantees item is a PR (see the `pull_request` filter in runAlertStale).
const { data } = await github.rest.pulls.listRequestedReviewers({ owner, repo, pull_number: item.number });
const reviewers = (data.users || []).map((u) => u.login);
if (reviewers.length) return reviewers;
return item.user ? [item.user.login] : [];
}
async function runAlertStale({ github, context, core }) {
const { owner, repo } = context.repo;
await ensureAlertLabel(github, owner, repo);
let alerted = 0;
for (const [staleLabel, alertDays] of Object.entries(ALERT_DAYS_BY_STALE_LABEL)) {
const items = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: "open",
labels: staleLabel,
per_page: 100,
});
for (const item of items) {
if (!item.pull_request) continue; // PR-only workflow; ignore issues even if labeled manually
const labelNames = item.labels.map((l) => (typeof l === "string" ? l : l.name));
if (labelNames.includes(ALERT_LABEL)) continue; // already alerted this episode
const labelAddedAt = await findLabelAddedAt(github, owner, repo, item.number, staleLabel);
if (!labelAddedAt || daysSince(labelAddedAt) < alertDays) continue;
const targets = await pickAlertTargets(github, owner, repo, item);
const mentions = targets.map((t) => `@${t}`).join(" ");
await github.rest.issues.addLabels({ owner, repo, issue_number: item.number, labels: [ALERT_LABEL] });
await github.rest.issues.createComment({
owner,
repo,
issue_number: item.number,
body:
`${mentions ? mentions + " " : ""}this has been marked \`${staleLabel}\` for ${alertDays}+ days with no qualifying activity. ` +
`Could you decide whether to keep it open or close it? Removing the \`${staleLabel}\` label will reset this check.`,
});
alerted += 1;
}
}
core.info(`alert-stale: sent ${alerted} alert(s)`);
}
module.exports = { runAlertStale };