// Follows up on stale external-contributor PRs with active requested-changes // reviews: posts escalating reminders, then closes after the final warning. // Invoked from .github/workflows/stale_requested_changes_prs.yml via // actions/github-script; `github` and `context` are injected by that action. module.exports = async ({ github, context }) => { const { owner, repo } = context.repo; // Default to "full" if unset, because scheduled (cron) runs don't provide inputs const mode = (context.payload.inputs && context.payload.inputs.mode) || 'full'; const canRemind = mode === 'reminder-only' || mode === 'full'; const canClose = mode === 'full'; const DAY_MS = 24 * 60 * 60 * 1000; const REMINDER_DAYS = [7, 10]; const FINAL_WARNING_DAY = 10; const CLOSE_DAY = 14; const EXTERNAL_LABEL = 'external-contributor'; const EXEMPT_LABEL = 'no-autoclose'; const BOT_LOGIN = 'github-actions[bot]'; const markerFor = (stage) => ``; const now = Date.now(); const ts = (value) => (value ? new Date(value).getTime() : 0); // Returns the submitted_at (ms) of the most recent active requested-changes // review, or 0 when no reviewer currently has changes requested. A reviewer's // latest *decisive* review (APPROVED / CHANGES_REQUESTED / DISMISSED, tracked // separately from COMMENTED so a later comment-reply doesn't flip state) // determines their effective state. const latestActiveChangesRequestedAt = (reviews) => { const decisive = new Map(); const latest = new Map(); for (const r of reviews) { const login = r.user && r.user.login; if (!login) continue; const at = ts(r.submitted_at); if (!latest.has(login) || at > latest.get(login).at) { latest.set(login, { state: r.state, at }); } if (['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(r.state)) { if (!decisive.has(login) || at > decisive.get(login).at) { decisive.set(login, { state: r.state, at }); } } } let latestAt = 0; for (const login of latest.keys()) { const eff = decisive.get(login) || latest.get(login); if (eff.state === 'CHANGES_REQUESTED') latestAt = Math.max(latestAt, eff.at); } return latestAt; }; const openPRs = await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', per_page: 100, }); const summary = []; for (const pr of openPRs) { const labels = (pr.labels || []).map((l) => l.name); const author = pr.user && pr.user.login; if (pr.draft) continue; if (pr.user && pr.user.type === 'Bot') continue; // Skip internal member PRs and PRs with the no-autoclose label if (!labels.includes(EXTERNAL_LABEL)) continue; if (labels.includes(EXEMPT_LABEL)) continue; const reviews = await github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: pr.number, per_page: 100, }); const changesRequestedAt = latestActiveChangesRequestedAt(reviews); if (!changesRequestedAt) continue; const [issueComments, reviewComments] = await Promise.all([ github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 }), github.paginate(github.rest.pulls.listReviewComments, { owner, repo, pull_number: pr.number, per_page: 100 }), ]); // Server-recorded head-branch push time. commit.pushedDate and the // force-push event timestamp are GitHub-controlled, so a backdated or // force-pushed commit still resets the timer (unlike commit author/committer // dates, which the contributor controls). const pushData = await github.graphql( `query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { commits(last: 1) { nodes { commit { pushedDate committedDate } } } timelineItems(last: 50, itemTypes: [HEAD_REF_FORCE_PUSHED_EVENT]) { nodes { ... on HeadRefForcePushedEvent { createdAt } } } } } }`, { owner, repo, number: pr.number } ); const prGraph = pushData.repository.pullRequest; const headCommit = (prGraph.commits.nodes[0] || {}).commit || {}; // Staleness anchor: the most recent active requested-changes review, plus // author-driven events that reset the timer (PR creation, head-branch push, // and comments by the PR author). Anchoring on the latest review ensures a // subsequent requested-changes review restarts the window instead of // measuring from stale, pre-review author activity. Maintainer/third-party/ // bot activity is otherwise ignored. let lastActivity = Math.max(ts(pr.created_at), changesRequestedAt); lastActivity = Math.max(lastActivity, ts(headCommit.pushedDate || headCommit.committedDate)); for (const ev of prGraph.timelineItems.nodes) { lastActivity = Math.max(lastActivity, ts(ev.createdAt)); } for (const c of issueComments) { if (c.user && c.user.login === author) lastActivity = Math.max(lastActivity, ts(c.created_at)); } for (const c of reviewComments) { if (c.user && c.user.login === author) lastActivity = Math.max(lastActivity, ts(c.created_at)); } const inactiveDays = (now - lastActivity) / DAY_MS; // A stage counts as sent only when its marker comment was posted by our own // workflow identity in the current window (at/after the last activity). const sentStages = new Set(); for (const c of issueComments) { if (!c.user || c.user.login !== BOT_LOGIN) continue; if (ts(c.created_at) < lastActivity) continue; for (const stage of REMINDER_DAYS) { if ((c.body || '').includes(markerFor(stage))) sentStages.add(stage); } } const dueStage = [...REMINDER_DAYS].reverse().find((s) => inactiveDays >= s); const finalWarningSent = sentStages.has(FINAL_WARNING_DAY); const shouldClose = inactiveDays >= CLOSE_DAY && finalWarningSent; let action = 'none'; if (shouldClose) action = 'close'; else if (dueStage !== undefined && !sentStages.has(dueStage)) action = `remind:${dueStage}`; const days = Math.floor(inactiveDays); console.log(`PR #${pr.number} (@${author}, inactive ${days}d): ${action}${action === 'none' ? '' : ` [mode=${mode}]`}`); if (action === 'none') continue; summary.push(`#${pr.number} ${action} (${days}d)`); if (action === 'close') { if (!canClose) continue; await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body: `Closing this pull request because the requested changes have gone unaddressed for over ${CLOSE_DAY} days. If you'd like to continue, push your updates and reopen the PR (or comment to ask a maintainer to reopen) — we'd be glad to pick it back up.`, }); await github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'closed' }); continue; } if (!canRemind) continue; const stage = Number(action.split(':')[1]); const remaining = Math.max(1, CLOSE_DAY - days); const body = stage === FINAL_WARNING_DAY ? `Hi @${author} — final reminder: a reviewer requested changes on this PR and it has been inactive for ${days} days. It will be **automatically closed in about ${remaining} day(s)** unless you push updates or reply. Maintainers can apply the \`${EXEMPT_LABEL}\` label to keep it open.\n\n${markerFor(stage)}` : `Hi @${author} — a reviewer requested changes on this PR and it hasn't had activity from you in ${days} days. When you get a chance, please push updates or reply to the review so a reviewer can take another look. Without activity, this PR will be automatically closed after ${CLOSE_DAY} days of inactivity.\n\n${markerFor(stage)}`; await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body }); } console.log(`mode=${mode}; acted on ${summary.length} PR(s): ${summary.join(', ') || 'none'}`); };