Skip to content

Commit f8a96e7

Browse files
ci: add abandoned PR policy and automated stale-PR reminders (#1589)
* docs: add abandoned PR policy to CONTRIBUTING.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: add stale PR reminder and takeover workflow Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: address review — per-PR error isolation, maintainer-only clock, dry-run dispatch - Wrap each PR in try/catch so one failing PR cannot abort the run - Add takeover label before posting the day-7 comment (idempotent retry) - Start the clock only on maintainer activity (author_association) and only for CHANGES_REQUESTED/COMMENTED reviews; APPROVED no longer counts - workflow_dispatch gains a dry_run input (default true) gating all writes - Pre-create the takeover-eligible label with color and description Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f248ece commit f8a96e7

2 files changed

Lines changed: 280 additions & 0 deletions

File tree

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
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+
}

CONTRIBUTING.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ uv run lefthook install --reset-hooks-path
5050
- **Docs**: Update README.md for user-facing changes
5151
- **PRs**: Use the template, ensure tests pass
5252

53+
## 💤 Abandoned PRs
54+
55+
If a maintainer requests changes or an update on a PR and there is no response or
56+
activity from the author for **7 days**, a maintainer may, at their discretion,
57+
take over the PR (push to it or supersede it) or close it.
58+
59+
- An automated reminder is posted on the PR after 4 and 6 days without a response.
60+
- Any activity from the author (a comment is enough) resets the clock.
61+
- This is a guideline, not a hard rule — maintainers may leave a PR open longer
62+
when the situation warrants it.
63+
5364
## 🏗️ Stuck?
5465

5566
- Open an [Issue](../../issues).

0 commit comments

Comments
 (0)