Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 70 additions & 14 deletions .github/scripts/draft-pr-policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 -->";
Expand Down Expand Up @@ -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()];

Expand All @@ -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);

Expand All @@ -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)`);
Expand Down
125 changes: 125 additions & 0 deletions .github/scripts/mark-stale.js
Original file line number Diff line number Diff line change
@@ -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 };
34 changes: 17 additions & 17 deletions .github/workflows/stale-pr-policy.yml
Original file line number Diff line number Diff line change
@@ -1,40 +1,40 @@
# 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

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'
Expand All @@ -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
Expand Down
Loading