diff --git a/.github/scripts/draft-pr-policy.js b/.github/scripts/draft-pr-policy.js index 498718eb584..dfa46880569 100644 --- a/.github/scripts/draft-pr-policy.js +++ b/.github/scripts/draft-pr-policy.js @@ -3,6 +3,8 @@ // 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. +'use strict'; + const LABEL = "draft-stale"; const STALE_DAYS = 60; const KEEPALIVE_MARKER = ""; @@ -36,6 +38,7 @@ async function findKeepAliveComment(github, owner, repo, issue_number) { // pr.updated_at is bumped by metadata-only changes (reviewer requested/removed, labels, // milestone, assignee, etc.), which would let a draft dodge the staleness check forever // without any real work happening. Use the latest commit/comment/review activity instead. +// Bot comments are excluded — they represent automated activity, not real human progress. async function lastRealActivityAt(github, owner, repo, pr) { const timestamps = [new Date(pr.created_at).getTime()]; @@ -46,23 +49,85 @@ async function lastRealActivityAt(github, owner, repo, pr) { } const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 }); - for (const c of comments) timestamps.push(new Date(c.created_at).getTime()); + for (const c of comments) { + if (c.user?.type !== "Bot") timestamps.push(new Date(c.created_at).getTime()); + } const reviews = await github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: pr.number, per_page: 100 }); for (const r of reviews) { - if (r.submitted_at) timestamps.push(new Date(r.submitted_at).getTime()); + if (r.submitted_at && r.user?.type !== "Bot") timestamps.push(new Date(r.submitted_at).getTime()); } const reviewComments = await github.paginate(github.rest.pulls.listReviewComments, { owner, repo, pull_number: pr.number, per_page: 100 }); - for (const rc of reviewComments) timestamps.push(new Date(rc.created_at).getTime()); + for (const rc of reviewComments) { + if (rc.user?.type !== "Bot") timestamps.push(new Date(rc.created_at).getTime()); + } return Math.max(...timestamps); } +// Returns true and removes the label if the PR has a keepalive signal: +// (a) the checkbox in the bot's keepalive comment is checked, OR +// (b) a non-bot human posted a comment after the keepalive comment was created. +// Case (b) handles external contributors who lack write access to edit the bot's comment. +async function processStaleSignals(github, owner, repo, pr) { + const keepAliveComment = await findKeepAliveComment(github, owner, repo, pr.number); + + const checkboxChecked = keepAliveComment && KEEPALIVE_CHECKED_RE.test(keepAliveComment.body); + + let humanCommentAfterStale = false; + if (!checkboxChecked && keepAliveComment) { + const staleDate = new Date(keepAliveComment.created_at); + const allComments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 }); + for (const c of allComments) { + if (c.user?.type !== "Bot" && new Date(c.created_at) > staleDate) { + humanCommentAfterStale = true; + break; + } + } + } + + if (checkboxChecked || humanCommentAfterStale) { + 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.", + }); + return true; + } + return false; +} + async function runDraftPolicy({ github, context, core }) { const { owner, repo } = context.repo; await ensureLabel(github, owner, repo); + // Fast path for issue_comment: only check the PR that received the comment. + // This avoids scanning all open PRs on every comment event. + if (context.eventName === "issue_comment") { + const issue = context.payload.issue; + // issue_comment fires for issues too; skip non-PRs (PRs have a pull_request field). + if (!issue?.pull_request) return; + + let prData; + try { + ({ data: prData } = await github.rest.pulls.get({ owner, repo, pull_number: issue.number })); + } catch (e) { + return; + } + if (!prData.draft) return; + + const labelNames = prData.labels.map((l) => l.name); + if (!labelNames.includes(LABEL)) return; + + const removed = await processStaleSignals(github, owner, repo, prData); + core.info(`draft-pr-policy: PR #${issue.number} — ${removed ? "un-staled" : "still stale"} (issue_comment trigger)`); + return; + } + + // Full scan path for schedule / workflow_dispatch. const prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: "open", per_page: 100 }); const drafts = prs.filter((pr) => pr.draft); @@ -81,23 +146,14 @@ async function runDraftPolicy({ github, context, core }) { `${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. ` + + `Checking the box or posting a new comment 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.", - }); - } + await processStaleSignals(github, owner, repo, pr); } core.info(`draft-pr-policy: checked ${drafts.length} draft PR(s)`); diff --git a/.github/scripts/mark-stale.js b/.github/scripts/mark-stale.js new file mode 100644 index 00000000000..26ee706465e --- /dev/null +++ b/.github/scripts/mark-stale.js @@ -0,0 +1,125 @@ +// Marks open, non-draft PRs as stale based on last *meaningful* activity: +// non-bot comments, review submissions, or commits pushed to the branch. +// Bot-only events (e.g. /remove-reviewer acknowledgment comments, CI status +// posts) do not reset the stale countdown. +const DAYS_BEFORE_STALE = 30; +const STALE_LABEL = "stale"; +const EXEMPT_LABELS = ["pinned", "security"]; +const STALE_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."; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const daysSince = (iso) => (Date.now() - new Date(iso).getTime()) / MS_PER_DAY; + +async function ensureLabel(github, owner, repo, name) { + try { + await github.rest.issues.getLabel({ owner, repo, name }); + } catch (e) { + if (e.status !== 404) throw e; + await github.rest.issues.createLabel({ + owner, + repo, + name, + color: "ededed", + description: "No meaningful activity for 30+ days", + }); + } +} + +async function lastMeaningfulActivity(github, owner, repo, pr) { + let latest = new Date(pr.created_at); + + // Comments from non-bot users only + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pr.number, + per_page: 100, + }); + for (const c of comments) { + if (c.user?.type === "Bot") continue; + const d = new Date(c.created_at); + if (d > latest) latest = d; + } + + // Reviews from non-bot users only + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, + repo, + pull_number: pr.number, + per_page: 100, + }); + for (const r of reviews) { + if (r.user?.type === "Bot") continue; + const d = new Date(r.submitted_at); + if (d > latest) latest = d; + } + + // Commits pushed to the branch + const commits = await github.paginate(github.rest.pulls.listCommits, { + owner, + repo, + pull_number: pr.number, + per_page: 100, + }); + for (const c of commits) { + const d = new Date(c.commit.committer?.date || c.commit.author?.date); + if (d > latest) latest = d; + } + + return latest; +} + +async function runMarkStale({ github, context, core }) { + const { owner, repo } = context.repo; + await ensureLabel(github, owner, repo, STALE_LABEL); + + const prs = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + per_page: 100, + }); + + let marked = 0, + unStaled = 0; + for (const pr of prs) { + if (pr.draft) continue; + + const labelNames = pr.labels.map((l) => (typeof l === "string" ? l : l.name)); + const exempt = EXEMPT_LABELS.some((l) => labelNames.includes(l)); + const alreadyStale = labelNames.includes(STALE_LABEL); + + const lastActive = await lastMeaningfulActivity(github, owner, repo, pr); + const days = daysSince(lastActive.toISOString()); + + if (alreadyStale && days < DAYS_BEFORE_STALE) { + // Meaningful human activity after stale was applied — remove the label + await github.rest.issues + .removeLabel({ owner, repo, issue_number: pr.number, name: STALE_LABEL }) + .catch(() => {}); + core.info(`Un-staled PR #${pr.number} (last meaningful activity ${Math.floor(days)} days ago)`); + unStaled++; + } else if (!alreadyStale && !exempt && days >= DAYS_BEFORE_STALE) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pr.number, + labels: [STALE_LABEL], + }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: STALE_MESSAGE, + }); + core.info(`Marked PR #${pr.number} as stale (${Math.floor(days)} days since last meaningful activity)`); + marked++; + } + } + + core.info(`mark-stale: marked=${marked} un-staled=${unStaled}`); +} + +module.exports = { runMarkStale }; diff --git a/.github/workflows/stale-pr-policy.yml b/.github/workflows/stale-pr-policy.yml index 16401da8eb3..ee077623661 100644 --- a/.github/workflows/stale-pr-policy.yml +++ b/.github/workflows/stale-pr-policy.yml @@ -1,10 +1,10 @@ # 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, +# Staleness is measured by the last *meaningful* activity: non-bot comments, +# review submissions, or commits — bot-only events (e.g. /remove-reviewer +# acknowledgments) do not reset the countdown. Draft PRs get a longer window +# handled separately. 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 @@ -12,29 +12,29 @@ on: workflow_dispatch: schedule: - cron: "0 6 * * *" + issue_comment: + types: [created] permissions: contents: read jobs: mark-stale: - if: github.repository_owner == 'HDFGroup' + if: github.repository_owner == 'HDFGroup' && github.event_name != 'issue_comment' runs-on: ubuntu-latest permissions: + contents: read issues: write pull-requests: write steps: - - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.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. + script: | + const { runMarkStale } = require('${{ github.workspace }}/.github/scripts/mark-stale.js'); + await runMarkStale({ github, context, core }); draft-pr-policy: if: github.repository_owner == 'HDFGroup' @@ -55,7 +55,7 @@ jobs: alert-stale: needs: [mark-stale, draft-pr-policy] - if: github.repository_owner == 'HDFGroup' + if: github.repository_owner == 'HDFGroup' && github.event_name != 'issue_comment' runs-on: ubuntu-latest permissions: contents: read