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 });