mirror of
https://github.com/HDFGroup/hdf5.git
synced 2026-09-25 04:09:44 +03:00
ci: replace actions/stale with bot-aware mark-stale script (#6477)
* ci: replace actions/stale with bot-aware mark-stale script actions/stale uses updatedAt to measure inactivity, so any bot event (e.g. the /remove-reviewer acknowledgment comment) resets the stale countdown even when there has been no meaningful human activity for months. PR #6332 was last touched by a human on 2026-04-02 but was not flagged because a reviewer-removal on 2026-06-11 refreshed the timestamp. Replace the actions/stale step with a custom mark-stale.js script (same pattern as alert-stale.js) that only counts non-bot comments, non-bot review submissions, and commits as meaningful activity. The script also removes the stale label if such activity occurs after the label was applied. * ci: fix draft-stale keepalive for external contributors Two bugs with the keepalive checkbox on draft-stale PRs: 1. External contributors (fork authors) lack write access to edit the bot's comment, so clicking the checkbox silently fails for them. Fix: also treat a new non-bot comment posted after the keepalive comment as a sufficient keepalive signal. 2. The stale label could take up to 24 hours to be removed (daily cron only). Fix: add an issue_comment.created trigger so draft-pr-policy fires immediately when someone comments on a stale draft PR. mark-stale and alert-stale are guarded to only run on schedule/workflow_dispatch, not on every comment. Also fix lastRealActivityAt to exclude bot comments (matching mark-stale.js), so the keepalive and "Thanks for confirming" bot comments don't count as real activity when measuring staleness.
This commit is contained in:
@@ -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 = "<!-- draft-stale-keepalive -->";
|
||||
@@ -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)`);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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:
|
||||
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.
|
||||
persist-credentials: false
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user