From a7eeb4fa9bc53261dc55a027571bceb85a92dcac Mon Sep 17 00:00:00 2001 From: Scot Breitenfeld Date: Fri, 19 Jun 2026 11:32:43 -0500 Subject: [PATCH] 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 --- .github/scripts/alert-stale.js | 89 +++++++++++++++++++++++++++ .github/scripts/draft-pr-policy.js | 79 ++++++++++++++++++++++++ .github/workflows/stale-pr-policy.yml | 72 ++++++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 .github/scripts/alert-stale.js create mode 100644 .github/scripts/draft-pr-policy.js create mode 100644 .github/workflows/stale-pr-policy.yml diff --git a/.github/scripts/alert-stale.js b/.github/scripts/alert-stale.js new file mode 100644 index 00000000000..53bf046a3ee --- /dev/null +++ b/.github/scripts/alert-stale.js @@ -0,0 +1,89 @@ +// 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 }; diff --git a/.github/scripts/draft-pr-policy.js b/.github/scripts/draft-pr-policy.js new file mode 100644 index 00000000000..1abc5e39a79 --- /dev/null +++ b/.github/scripts/draft-pr-policy.js @@ -0,0 +1,79 @@ +// Draft PRs get a longer inactivity window than ready PRs, and only checking +// the keep-alive box in the bot's own comment resets the clock -- a +// CI-triggered push or unrelated comment shouldn't make an abandoned draft +// look "fresh". Checking a box is also easier to discover and use than +// remembering an exact phrase to comment. +const LABEL = "draft-stale"; +const STALE_DAYS = 60; +const KEEPALIVE_MARKER = ""; +const KEEPALIVE_CHECKBOX = "- [ ] Still working on this -- check this box to keep the draft open"; +const KEEPALIVE_CHECKED_RE = /-\s*\[[xX]\]\s*Still working on this/; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const daysSince = (isoDate) => (Date.now() - new Date(isoDate).getTime()) / MS_PER_DAY; + +async function ensureLabel(github, owner, repo) { + try { + await github.rest.issues.getLabel({ owner, repo, name: LABEL }); + } catch (err) { + if (err.status !== 404) throw err; + await github.rest.issues.createLabel({ + owner, + repo, + name: LABEL, + color: "5319e7", + description: "Draft PR with no activity past the draft staleness window", + }); + } +} + +async function findKeepAliveComment(github, owner, repo, issue_number) { + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 }); + const marked = comments.filter((c) => c.body?.includes(KEEPALIVE_MARKER)); + return marked.length ? marked[marked.length - 1] : null; // most recent stale episode's comment +} + +async function runDraftPolicy({ github, context, core }) { + const { owner, repo } = context.repo; + await ensureLabel(github, owner, repo); + + const prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: "open", per_page: 100 }); + const drafts = prs.filter((pr) => pr.draft); + + for (const pr of drafts) { + const labelNames = pr.labels.map((l) => l.name); + + if (!labelNames.includes(LABEL)) { + if (daysSince(pr.updated_at) >= STALE_DAYS) { + await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: + `${KEEPALIVE_MARKER}\n` + + `This draft has had no activity for ${STALE_DAYS} days and has been marked \`${LABEL}\`.\n\n` + + `${KEEPALIVE_CHECKBOX}\n\n` + + `Checking the box is the only thing that resets this -- a commit or other automated update alone won't. ` + + `Otherwise it will be flagged for maintainer review.`, + }); + } + continue; + } + + const keepAliveComment = await findKeepAliveComment(github, owner, repo, pr.number); + if (keepAliveComment && KEEPALIVE_CHECKED_RE.test(keepAliveComment.body)) { + await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: LABEL }).catch(() => {}); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: "Thanks for confirming — removing the stale label.", + }); + } + } + + core.info(`draft-pr-policy: checked ${drafts.length} draft PR(s)`); +} + +module.exports = { runDraftPolicy }; diff --git a/.github/workflows/stale-pr-policy.yml b/.github/workflows/stale-pr-policy.yml new file mode 100644 index 00000000000..16401da8eb3 --- /dev/null +++ b/.github/workflows/stale-pr-policy.yml @@ -0,0 +1,72 @@ +# Flags inactive PRs instead of letting them sit indefinitely. Issues are +# intentionally untouched by this workflow. +# Ready (non-draft) PRs use actions/stale for labeling. Draft PRs get a +# longer window and only reset by checking a keep-alive box in the bot's +# own comment, since a stale draft can otherwise look "fresh" from CI +# pushes alone. Nothing is ever auto-closed: once a PR has been stale past +# its alert threshold, the assignee (falling back to requested reviewers, +# then the author) is pinged to decide whether to close it or keep it open. +name: Stale PR policy + +on: + workflow_dispatch: + schedule: + - cron: "0 6 * * *" + +permissions: + contents: read + +jobs: + mark-stale: + if: github.repository_owner == 'HDFGroup' + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + with: + exempt-draft-pr: true + days-before-issue-stale: -1 # issues are out of scope for this workflow + days-before-pr-stale: 30 + days-before-close: -1 # never auto-close; see alert-stale job + stale-pr-label: stale + exempt-pr-labels: "pinned,security" + stale-pr-message: > + This pull request has had no activity for 30 days and has been marked stale. + Push a commit or comment to keep it open, or it will be flagged for maintainer review. + + draft-pr-policy: + if: github.repository_owner == 'HDFGroup' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const { runDraftPolicy } = require('${{ github.workspace }}/.github/scripts/draft-pr-policy.js'); + await runDraftPolicy({ github, context, core }); + + alert-stale: + needs: [mark-stale, draft-pr-policy] + if: github.repository_owner == 'HDFGroup' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const { runAlertStale } = require('${{ github.workspace }}/.github/scripts/alert-stale.js'); + await runAlertStale({ github, context, core });