|
| 1 | +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +const DAY = 24 * 60 * 60 * 1000; |
| 5 | +const WAITING = 'waiting for feedback'; |
| 6 | +const STALE = 'stale'; |
| 7 | +const BOT_LOGINS = new Set([ |
| 8 | + 'coderabbitai', 'coderabbitai[bot]', 'trtllm-agent', |
| 9 | + 'svc-trtllm-gh-bot', 'tensorrt-cicd', 'github-actions[bot]', |
| 10 | +]); |
| 11 | + |
| 12 | +function isBot(user) { |
| 13 | + const login = (user?.login || '').toLowerCase(); |
| 14 | + return user?.type === 'Bot' || login.endsWith('[bot]') || BOT_LOGINS.has(login); |
| 15 | +} |
| 16 | + |
| 17 | +// Timeline `committed` events carry git author/committer objects (name, email, |
| 18 | +// date) without a GitHub login, so bots are recognised by name or email. |
| 19 | +function isBotCommitter(person) { |
| 20 | + const name = (person?.name || '').toLowerCase(); |
| 21 | + const email = (person?.email || '').toLowerCase(); |
| 22 | + return name.endsWith('[bot]') || BOT_LOGINS.has(name) || email.endsWith('[bot]@users.noreply.github.com'); |
| 23 | +} |
| 24 | + |
| 25 | +function timestamp(value) { |
| 26 | + const time = Date.parse(value); |
| 27 | + if (!Number.isFinite(time)) throw new Error(`Missing or invalid activity timestamp: ${value}`); |
| 28 | + return time; |
| 29 | +} |
| 30 | + |
| 31 | +// Only these timeline events are responses to a feedback request. Everything |
| 32 | +// else (labels, assignments, review requests, cross-references, milestones, |
| 33 | +// renames) is maintainer bookkeeping or unrelated traffic, regardless of actor. |
| 34 | +const ACTIVITY_EVENTS = new Set(['head_ref_force_pushed', 'reopened', 'ready_for_review']); |
| 35 | + |
| 36 | +// Returns one of: skip, mark, answered (author responded before the PR went |
| 37 | +// stale: drop waiting for feedback), unstale (author responded: drop both labels), |
| 38 | +// lift (another human responded: drop only stale), baseline, close. |
| 39 | +function decide({issue, timeline, comments, reviews, reviewComments, head}, now) { |
| 40 | + const labels = issue.labels.map(label => label.name.toLowerCase()); |
| 41 | + if (issue.state !== 'open' || !issue.pull_request || !labels.includes(WAITING)) return 'skip'; |
| 42 | + const author = (issue.user?.login || '').toLowerCase(); |
| 43 | + const isAuthor = user => author !== '' && (user?.login || '').toLowerCase() === author; |
| 44 | + let lastActivity = timestamp(issue.created_at); |
| 45 | + let authorActivity = lastActivity; |
| 46 | + let waitingAt = lastActivity; |
| 47 | + let waitingKnown = false; |
| 48 | + let waitingRemovedAt; |
| 49 | + let staleAt; |
| 50 | + const record = (time, byAuthor) => { |
| 51 | + lastActivity = Math.max(lastActivity, time); |
| 52 | + if (byAuthor) authorActivity = Math.max(authorActivity, time); |
| 53 | + }; |
| 54 | + for (const event of timeline) { |
| 55 | + const label = event.label?.name?.toLowerCase(); |
| 56 | + if (label === WAITING && event.event === 'labeled') { waitingAt = timestamp(event.created_at); waitingKnown = true; } |
| 57 | + if (label === WAITING && event.event === 'unlabeled') waitingRemovedAt = timestamp(event.created_at); |
| 58 | + if (label === STALE && event.event === 'labeled') staleAt = timestamp(event.created_at); |
| 59 | + if (label === STALE && event.event === 'unlabeled') staleAt = undefined; |
| 60 | + if (!ACTIVITY_EVENTS.has(event.event) || isBot(event.actor)) continue; |
| 61 | + record(timestamp(event.created_at), isAuthor(event.actor)); |
| 62 | + } |
| 63 | + // Commits are counted as author activity: they come from the PR branch and |
| 64 | + // the git identity cannot be mapped to a login. Unknown identities must not |
| 65 | + // cause a PR with new code to be closed, so only known bots are excluded. |
| 66 | + for (const event of timeline.filter(event => event.event === 'committed')) { |
| 67 | + if (isBotCommitter(event.author) && isBotCommitter(event.committer)) continue; |
| 68 | + record(timestamp(event.committer?.date || event.created_at), true); |
| 69 | + } |
| 70 | + for (const item of [...comments, ...reviews, ...reviewComments]) { |
| 71 | + if (isBot(item.user) || item.state === 'PENDING') continue; |
| 72 | + record(timestamp(item.updated_at || item.submitted_at || item.created_at), isAuthor(item.user)); |
| 73 | + } |
| 74 | + // A pushed commit can retain an old commit date, and an ordinary push leaves |
| 75 | + // no timeline event. Recorded heads catch these updates as well as rebases, |
| 76 | + // even when the committer is an automation. `assessment-head` is written by |
| 77 | + // the external PR-triage publisher (svc-trtllm-gh-bot) when it labels a PR; |
| 78 | + // `stale-head` and `stale-baseline` by this script. All record the full |
| 79 | + // 40-hex head SHA. Newest record first. |
| 80 | + const headRecords = comments.filter(comment => |
| 81 | + ['svc-trtllm-gh-bot', 'github-actions[bot]'].includes(comment.user?.login) |
| 82 | + ).flatMap(comment => { |
| 83 | + const created = timestamp(comment.created_at); |
| 84 | + const body = comment.body || ''; |
| 85 | + const initial = body.match(/<!-- (?:assessment|stale)-head:([0-9a-f]+) -->/); |
| 86 | + const recovery = body.match(/<!-- stale-baseline:(\d+) head:([0-9a-f]+) -->/); |
| 87 | + if (recovery) return [{head: recovery[2], created, staleAt: Number(recovery[1])}]; |
| 88 | + if (initial) return [{head: initial[1], created}]; |
| 89 | + return []; |
| 90 | + }).sort((a, b) => b.created - a.created); |
| 91 | + if (!labels.includes(STALE)) { |
| 92 | + // The label workflow clears the request on comments only; pushes and formal |
| 93 | + // reviews by the author are answers too, as is a head that moved since the |
| 94 | + // newest head recorded for this request. Legacy PRs without a label event |
| 95 | + // have no request time to compare against and keep the label. |
| 96 | + const requestHead = headRecords.find(record => record.created >= waitingAt)?.head; |
| 97 | + if (waitingKnown && authorActivity > waitingAt) return 'answered'; |
| 98 | + if (waitingKnown && head && requestHead && head !== requestHead) return 'answered'; |
| 99 | + return now - Math.max(lastActivity, waitingAt) >= 14 * DAY ? 'mark' : 'skip'; |
| 100 | + } |
| 101 | + // Never infer a closure deadline from updated_at: bot writes also update it. |
| 102 | + if (staleAt === undefined) throw new Error('Stale label has no corresponding timeline event'); |
| 103 | + // The label workflow removes only `waiting for feedback` when the author |
| 104 | + // replies and re-adds it on the next maintainer comment, leaving `stale` from |
| 105 | + // the previous cycle in place. A request withdrawn and re-issued after the |
| 106 | + // stale label starts a fresh cycle: drop `stale` and let the pre-stale path |
| 107 | + // mark again. Labels applied together, in any order, do not qualify. |
| 108 | + if (waitingRemovedAt !== undefined && waitingRemovedAt >= staleAt && waitingAt > waitingRemovedAt) return 'lift'; |
| 109 | + const baseline = headRecords.find(record => record.staleAt === undefined |
| 110 | + ? record.created <= staleAt : record.staleAt === staleAt && record.created >= staleAt); |
| 111 | + const recordedHead = baseline?.head; |
| 112 | + if (head && recordedHead && head !== recordedHead) return 'unstale'; |
| 113 | + if (authorActivity > staleAt) return 'unstale'; |
| 114 | + if (lastActivity > staleAt) return 'lift'; |
| 115 | + // Legacy/manual stale labels may predate head tracking. Start a new grace |
| 116 | + // period rather than closing based on commit dates that do not record pushes. |
| 117 | + if (!recordedHead) return 'baseline'; |
| 118 | + return now - Math.max(staleAt, baseline.created) >= 14 * DAY ? 'close' : 'skip'; |
| 119 | +} |
| 120 | + |
| 121 | +async function readSnapshot(github, repo, number) { |
| 122 | + const args = {...repo, issue_number: number, per_page: 100}; |
| 123 | + const {data: issue} = await github.rest.issues.get(args); |
| 124 | + const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, args); |
| 125 | + const comments = await github.paginate(github.rest.issues.listComments, args); |
| 126 | + const pullArgs = {...repo, pull_number: number, per_page: 100}; |
| 127 | + const reviews = await github.paginate(github.rest.pulls.listReviews, pullArgs); |
| 128 | + const reviewComments = await github.paginate(github.rest.pulls.listReviewComments, pullArgs); |
| 129 | + const {data: pull} = await github.rest.pulls.get(pullArgs); |
| 130 | + return {issue, timeline, comments, reviews, reviewComments, head: pull.head.sha}; |
| 131 | +} |
| 132 | + |
| 133 | +async function run({github, context, core, now = Date.now(), dryRun = false}) { |
| 134 | + const repo = context.repo; |
| 135 | + const issues = await github.paginate(github.rest.issues.listForRepo, { |
| 136 | + ...repo, state: 'open', labels: WAITING, per_page: 100, |
| 137 | + }); |
| 138 | + for (const candidate of issues.filter(issue => issue.pull_request)) { |
| 139 | + const args = {...repo, issue_number: candidate.number}; |
| 140 | + try { |
| 141 | + const before = await readSnapshot(github, repo, candidate.number); |
| 142 | + const action = decide(before, now); |
| 143 | + core.info(`#${candidate.number}: ${action}${dryRun ? ' (dry run, not applied)' : ''}`); |
| 144 | + if (action === 'skip' || dryRun) continue; |
| 145 | + // Re-read all activity before writing; skip changing PRs until the next run. |
| 146 | + const fresh = await readSnapshot(github, repo, candidate.number); |
| 147 | + if (JSON.stringify(before) !== JSON.stringify(fresh)) { |
| 148 | + core.info(`#${candidate.number}: changed while checking; skipped`); |
| 149 | + continue; |
| 150 | + } |
| 151 | + if (action === 'unstale') { |
| 152 | + await github.rest.issues.removeLabel({...args, name: STALE}); |
| 153 | + await github.rest.issues.removeLabel({...args, name: WAITING}); |
| 154 | + await github.rest.issues.addLabels({...args, labels: ['Investigating']}); |
| 155 | + } else if (action === 'answered') { |
| 156 | + await github.rest.issues.removeLabel({...args, name: WAITING}); |
| 157 | + await github.rest.issues.addLabels({...args, labels: ['Investigating']}); |
| 158 | + } else if (action === 'lift') { |
| 159 | + // Another human responded; the team is still waiting on the author. |
| 160 | + await github.rest.issues.removeLabel({...args, name: STALE}); |
| 161 | + } else if (action === 'mark') { |
| 162 | + await github.rest.issues.createComment({...args, |
| 163 | + body: `PR has not received a human update in over 14 days. Adding stale label.\n\n<!-- stale-head:${fresh.head} -->`}); |
| 164 | + await github.rest.issues.addLabels({...args, labels: [STALE]}); |
| 165 | + } else if (action === 'baseline') { |
| 166 | + const staleEvent = fresh.timeline.filter(event => |
| 167 | + event.event === 'labeled' && event.label?.name?.toLowerCase() === STALE |
| 168 | + ).at(-1); |
| 169 | + await github.rest.issues.createComment({...args, |
| 170 | + body: `We are restarting the feedback window to allow a full 14 days for an update before automatic closure.\n\n<!-- stale-baseline:${timestamp(staleEvent.created_at)} head:${fresh.head} -->`}); |
| 171 | + } else if (action === 'close') { |
| 172 | + // Close before posting so a failed close cannot leave a misleading notice. |
| 173 | + await github.rest.pulls.update({...repo, pull_number: candidate.number, state: 'closed'}); |
| 174 | + await github.rest.issues.createComment({...args, |
| 175 | + body: 'This PR was closed because it has been 14 days without human activity since it was marked as stale.'}); |
| 176 | + } |
| 177 | + } catch (error) { |
| 178 | + core.setFailed(`#${candidate.number}: ${error.message}`); |
| 179 | + } |
| 180 | + } |
| 181 | +} |
| 182 | + |
| 183 | +module.exports = {run, decide, isBot, isBotCommitter}; |
0 commit comments