|
| 1 | +name: '🤖 Stale PR Reminders' |
| 2 | + |
| 3 | +# Implements the "Abandoned PRs" policy from CONTRIBUTING.md: when a maintainer |
| 4 | +# requests changes or an update on a contributor PR and the author goes silent, |
| 5 | +# post reminders after 4 and 6 days, then mark the PR takeover-eligible at 7 days. |
| 6 | +# Manual dispatch defaults to dry-run (logs only); scheduled runs are live. |
| 7 | + |
| 8 | +on: |
| 9 | + schedule: |
| 10 | + - cron: '30 8 * * *' # Daily at 08:30 UTC |
| 11 | + workflow_dispatch: |
| 12 | + inputs: |
| 13 | + dry_run: |
| 14 | + description: 'Log actions without posting comments or labels' |
| 15 | + type: boolean |
| 16 | + default: true |
| 17 | + |
| 18 | +concurrency: |
| 19 | + group: stale-pr-takeover |
| 20 | + cancel-in-progress: false |
| 21 | + |
| 22 | +permissions: |
| 23 | + pull-requests: write |
| 24 | + issues: write |
| 25 | + |
| 26 | +jobs: |
| 27 | + stale_pr_reminders: |
| 28 | + runs-on: ubuntu-latest |
| 29 | + steps: |
| 30 | + - name: Remind inactive PR authors and flag takeover-eligible PRs |
| 31 | + uses: actions/github-script@v9 |
| 32 | + with: |
| 33 | + script: | |
| 34 | + // HTML anchors — drift-resistant vs matching display text (same pattern as close-needs-info.yml) |
| 35 | + const REMINDER_1_MARKER = '<!-- stale-pr-bot:reminder-1 -->'; |
| 36 | + const REMINDER_2_MARKER = '<!-- stale-pr-bot:reminder-2 -->'; |
| 37 | + const TAKEOVER_MARKER = '<!-- stale-pr-bot:takeover-eligible -->'; |
| 38 | + const BOT_LOGIN = 'github-actions[bot]'; |
| 39 | + const TAKEOVER_LABEL = 'takeover-eligible'; |
| 40 | + const DAYS_REMINDER_1 = 4; |
| 41 | + const DAYS_REMINDER_2 = 6; |
| 42 | + const DAYS_TAKEOVER = 7; |
| 43 | + const MS_PER_DAY = 86400000; |
| 44 | + const POLICY_URL = `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/master/CONTRIBUTING.md#-abandoned-prs`; |
| 45 | + // Only people with push access start the clock, and PR authors with push |
| 46 | + // access are not subject to the takeover policy |
| 47 | + const MAINTAINER_ASSOCIATIONS = ['OWNER', 'MEMBER', 'COLLABORATOR']; |
| 48 | + // Review states that ask the author for something. APPROVED must not count — |
| 49 | + // an approved-but-unmerged PR is waiting on maintainers, not the author. |
| 50 | + const REQUEST_REVIEW_STATES = ['CHANGES_REQUESTED', 'COMMENTED']; |
| 51 | +
|
| 52 | + // workflow_dispatch inputs arrive as strings in the event payload; |
| 53 | + // scheduled runs have no inputs and are always live. |
| 54 | + const dryRun = context.eventName === 'workflow_dispatch' && |
| 55 | + String(context.payload.inputs?.dry_run ?? 'true') !== 'false'; |
| 56 | + if (dryRun) core.notice('Dry run — no comments or labels will be written'); |
| 57 | +
|
| 58 | + if (!dryRun) { |
| 59 | + // Pre-create the label with color/description so the maintainer filter |
| 60 | + // view is usable (addLabels would auto-create it default-gray otherwise) |
| 61 | + try { |
| 62 | + await github.rest.issues.createLabel({ |
| 63 | + owner: context.repo.owner, |
| 64 | + repo: context.repo.repo, |
| 65 | + name: TAKEOVER_LABEL, |
| 66 | + color: 'b60205', |
| 67 | + description: 'Author unresponsive for 7+ days — maintainers may take over or close (see CONTRIBUTING.md)', |
| 68 | + }); |
| 69 | + core.info(`Created label ${TAKEOVER_LABEL}`); |
| 70 | + } catch (err) { |
| 71 | + if (err.status !== 422) throw err; // 422 = label already exists |
| 72 | + } |
| 73 | + } |
| 74 | +
|
| 75 | + const prs = await github.paginate(github.rest.pulls.list, { |
| 76 | + owner: context.repo.owner, |
| 77 | + repo: context.repo.repo, |
| 78 | + state: 'open', |
| 79 | + per_page: 100, |
| 80 | + }); |
| 81 | +
|
| 82 | + core.info(`Found ${prs.length} open PRs`); |
| 83 | +
|
| 84 | + for (const pr of prs) { |
| 85 | + const prNumber = pr.number; |
| 86 | + const author = pr.user.login; |
| 87 | +
|
| 88 | + // Per-PR isolation: one failing PR (locked conversation, transient 5xx) |
| 89 | + // must not abort the run for every PR after it |
| 90 | + try { |
| 91 | + if (pr.draft) { |
| 92 | + core.info(`#${prNumber}: draft — skipping`); |
| 93 | + continue; |
| 94 | + } |
| 95 | + if (pr.user.type === 'Bot') { |
| 96 | + core.info(`#${prNumber}: bot author (${author}) — skipping`); |
| 97 | + continue; |
| 98 | + } |
| 99 | + if (MAINTAINER_ASSOCIATIONS.includes(pr.author_association)) { |
| 100 | + core.info(`#${prNumber}: maintainer author (${author}) — skipping`); |
| 101 | + continue; |
| 102 | + } |
| 103 | +
|
| 104 | + const [comments, reviewComments, reviews, commits] = await Promise.all([ |
| 105 | + github.paginate(github.rest.issues.listComments, { |
| 106 | + owner: context.repo.owner, |
| 107 | + repo: context.repo.repo, |
| 108 | + issue_number: prNumber, |
| 109 | + per_page: 100, |
| 110 | + }), |
| 111 | + github.paginate(github.rest.pulls.listReviewComments, { |
| 112 | + owner: context.repo.owner, |
| 113 | + repo: context.repo.repo, |
| 114 | + pull_number: prNumber, |
| 115 | + per_page: 100, |
| 116 | + }), |
| 117 | + github.paginate(github.rest.pulls.listReviews, { |
| 118 | + owner: context.repo.owner, |
| 119 | + repo: context.repo.repo, |
| 120 | + pull_number: prNumber, |
| 121 | + per_page: 100, |
| 122 | + }), |
| 123 | + github.paginate(github.rest.pulls.listCommits, { |
| 124 | + owner: context.repo.owner, |
| 125 | + repo: context.repo.repo, |
| 126 | + pull_number: prNumber, |
| 127 | + per_page: 100, |
| 128 | + }), |
| 129 | + ]); |
| 130 | +
|
| 131 | + // Latest activity by the PR author: PR creation, commits, comments, reviews. |
| 132 | + // Commits with no resolvable login count as author activity (fail-open: resets the clock). |
| 133 | + let lastAuthorActivity = new Date(pr.created_at).getTime(); |
| 134 | + for (const c of commits) { |
| 135 | + if (!c.author || c.author.login === author) { |
| 136 | + const t = new Date(c.commit.committer?.date || c.commit.author?.date || pr.created_at).getTime(); |
| 137 | + if (t > lastAuthorActivity) lastAuthorActivity = t; |
| 138 | + } |
| 139 | + } |
| 140 | + for (const c of [...comments, ...reviewComments]) { |
| 141 | + if (c.user.login === author) { |
| 142 | + const t = new Date(c.created_at).getTime(); |
| 143 | + if (t > lastAuthorActivity) lastAuthorActivity = t; |
| 144 | + } |
| 145 | + } |
| 146 | + for (const r of reviews) { |
| 147 | + if (r.user && r.user.login === author && r.submitted_at) { |
| 148 | + const t = new Date(r.submitted_at).getTime(); |
| 149 | + if (t > lastAuthorActivity) lastAuthorActivity = t; |
| 150 | + } |
| 151 | + } |
| 152 | +
|
| 153 | + // Latest request from a maintainer (push access, non-author, human) asking |
| 154 | + // for changes or an update. Drive-by comments from non-maintainers and bot |
| 155 | + // reviews/comments (Gemini, this workflow, etc.) never start or extend the clock. |
| 156 | + let lastMaintainerRequest = 0; |
| 157 | + for (const c of [...comments, ...reviewComments]) { |
| 158 | + if (c.user.login !== author && c.user.type !== 'Bot' && |
| 159 | + MAINTAINER_ASSOCIATIONS.includes(c.author_association)) { |
| 160 | + const t = new Date(c.created_at).getTime(); |
| 161 | + if (t > lastMaintainerRequest) lastMaintainerRequest = t; |
| 162 | + } |
| 163 | + } |
| 164 | + for (const r of reviews) { |
| 165 | + if (r.user && r.user.login !== author && r.user.type !== 'Bot' && r.submitted_at && |
| 166 | + MAINTAINER_ASSOCIATIONS.includes(r.author_association) && |
| 167 | + REQUEST_REVIEW_STATES.includes(r.state)) { |
| 168 | + const t = new Date(r.submitted_at).getTime(); |
| 169 | + if (t > lastMaintainerRequest) lastMaintainerRequest = t; |
| 170 | + } |
| 171 | + } |
| 172 | +
|
| 173 | + const waitingOnAuthor = lastMaintainerRequest > lastAuthorActivity; |
| 174 | + const hasTakeoverLabel = pr.labels.some(l => l.name === TAKEOVER_LABEL); |
| 175 | +
|
| 176 | + if (!waitingOnAuthor) { |
| 177 | + core.info(`#${prNumber}: not waiting on author — skipping`); |
| 178 | + // Author came back after the PR was flagged — clear the label |
| 179 | + if (hasTakeoverLabel) { |
| 180 | + if (dryRun) { |
| 181 | + core.info(`#${prNumber}: [dry-run] would remove label ${TAKEOVER_LABEL}`); |
| 182 | + continue; |
| 183 | + } |
| 184 | + core.info(`#${prNumber}: author active again — removing ${TAKEOVER_LABEL}`); |
| 185 | + try { |
| 186 | + await github.rest.issues.removeLabel({ |
| 187 | + owner: context.repo.owner, |
| 188 | + repo: context.repo.repo, |
| 189 | + issue_number: prNumber, |
| 190 | + name: TAKEOVER_LABEL, |
| 191 | + }); |
| 192 | + } catch (err) { |
| 193 | + if (err.status !== 404) core.warning(`#${prNumber}: failed to remove label: ${err.message}`); |
| 194 | + } |
| 195 | + } |
| 196 | + continue; |
| 197 | + } |
| 198 | +
|
| 199 | + const daysWaiting = (Date.now() - lastMaintainerRequest) / MS_PER_DAY; |
| 200 | + core.info(`#${prNumber}: waiting on @${author} for ${daysWaiting.toFixed(1)} days`); |
| 201 | +
|
| 202 | + // Only bot comments posted after the author's last activity belong to the |
| 203 | + // current stale cycle — older markers from a previous cycle don't count. |
| 204 | + const cycleBotComments = comments.filter(c => |
| 205 | + c.user.login === BOT_LOGIN && |
| 206 | + new Date(c.created_at).getTime() > lastAuthorActivity |
| 207 | + ); |
| 208 | + const hasMarker = marker => cycleBotComments.some(c => c.body.includes(marker)); |
| 209 | +
|
| 210 | + const postComment = async body => { |
| 211 | + if (dryRun) { |
| 212 | + core.info(`#${prNumber}: [dry-run] would comment: ${body.split('\n')[0]}`); |
| 213 | + return; |
| 214 | + } |
| 215 | + await github.rest.issues.createComment({ |
| 216 | + owner: context.repo.owner, |
| 217 | + repo: context.repo.repo, |
| 218 | + issue_number: prNumber, |
| 219 | + body, |
| 220 | + }); |
| 221 | + }; |
| 222 | +
|
| 223 | + if (daysWaiting >= DAYS_TAKEOVER) { |
| 224 | + if (hasMarker(TAKEOVER_MARKER)) { |
| 225 | + core.info(`#${prNumber}: already flagged takeover-eligible`); |
| 226 | + continue; |
| 227 | + } |
| 228 | + core.info(`#${prNumber}: flagging takeover-eligible`); |
| 229 | + // Label before comment: addLabels is idempotent, so if the comment |
| 230 | + // fails the next run retries both. Comment-first would strand the PR |
| 231 | + // publicly announced but unlabeled (the marker check blocks retries). |
| 232 | + if (dryRun) { |
| 233 | + core.info(`#${prNumber}: [dry-run] would add label ${TAKEOVER_LABEL}`); |
| 234 | + } else { |
| 235 | + await github.rest.issues.addLabels({ |
| 236 | + owner: context.repo.owner, |
| 237 | + repo: context.repo.repo, |
| 238 | + issue_number: prNumber, |
| 239 | + labels: [TAKEOVER_LABEL], |
| 240 | + }); |
| 241 | + } |
| 242 | + await postComment( |
| 243 | + `${TAKEOVER_MARKER}\nHi @${author} — there has been no response for ${DAYS_TAKEOVER} days since a maintainer requested changes or an update on this PR.\n\nPer our [abandoned PR policy](${POLICY_URL}), a maintainer may now take over this PR or close it at their discretion. If you are still working on it, just reply here and the clock resets.\n\n*Automated by PR Bot*` |
| 244 | + ); |
| 245 | + } else if (daysWaiting >= DAYS_REMINDER_2) { |
| 246 | + if (hasMarker(REMINDER_2_MARKER)) { |
| 247 | + core.info(`#${prNumber}: second reminder already posted`); |
| 248 | + continue; |
| 249 | + } |
| 250 | + core.info(`#${prNumber}: posting second reminder`); |
| 251 | + await postComment( |
| 252 | + `${REMINDER_2_MARKER}\nHi @${author} — a maintainer requested changes or an update on this PR ${DAYS_REMINDER_2} days ago and we haven't heard back.\n\nPer our [abandoned PR policy](${POLICY_URL}), after ${DAYS_TAKEOVER} days without a response a maintainer may take over or close this PR. A quick reply is enough to keep it yours.\n\n*Automated by PR Bot*` |
| 253 | + ); |
| 254 | + } else if (daysWaiting >= DAYS_REMINDER_1) { |
| 255 | + if (hasMarker(REMINDER_1_MARKER)) { |
| 256 | + core.info(`#${prNumber}: first reminder already posted`); |
| 257 | + continue; |
| 258 | + } |
| 259 | + core.info(`#${prNumber}: posting first reminder`); |
| 260 | + await postComment( |
| 261 | + `${REMINDER_1_MARKER}\nHi @${author} — just a friendly reminder that a maintainer requested changes or an update on this PR ${DAYS_REMINDER_1} days ago.\n\nWhen you have a moment, please respond or push an update. See our [abandoned PR policy](${POLICY_URL}) for details.\n\n*Automated by PR Bot*` |
| 262 | + ); |
| 263 | + } else { |
| 264 | + core.info(`#${prNumber}: ${(DAYS_REMINDER_1 - daysWaiting).toFixed(1)} days until first reminder`); |
| 265 | + } |
| 266 | + } catch (err) { |
| 267 | + core.warning(`#${prNumber}: failed — ${err.message} (continuing with next PR)`); |
| 268 | + } |
| 269 | + } |
0 commit comments