Skip to content

Commit f6fc6a2

Browse files
CopilotLms24claude
authored
chore: Add PR review reminder workflow (#20175)
Adds .github/workflows/pr-review-reminder.yml and scripts/pr-review-reminder.mjs. - Schedule: weekdays 10:00 UTC; also workflow_dispatch. - Skips draft PRs and PRs opened by Bot users. - Only GitHub pending requested reviewers (users/teams not yet reviewed). - Fires when ≥2 full elapsed business days (UTC) since last review_requested or last reminder for that login/team; weekends excluded; US/CA/AT holidays via Nager.Date (fallback: weekdays only if API fails). - One issue comment per PR; per-reviewer HTML markers; dedup using prior github-actions[bot] comments. - Individual @mentions: omit repo outside collaborators (listCollaborators affiliation: outside); on API failure, skip individuals (warn), teams unchanged. - Team @mentions: only pending team slug team-javascript-sdks. - Warn if pending reviewer/team has no matching review_requested timeline event. - Job skipped on workflow_dispatch for forks; schedule always runs on default branch. - GITHUB_TOKEN permissions: contents: read, issues: write, pull-requests: read. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Lms24 <8420481+Lms24@users.noreply.github.com> Co-authored-by: Lukas Stracke <lukas.stracke@sentry.io> Co-authored-by: Claude <noreply@anthropic.com>
1 parent efaf6cf commit f6fc6a2

File tree

2 files changed

+325
-0
lines changed

2 files changed

+325
-0
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: 'PR: Review Reminder'
2+
3+
on:
4+
workflow_dispatch:
5+
schedule:
6+
# Run on weekdays at 10:00 AM UTC. No new reminders can fire on weekends because
7+
# Saturday/Sunday are never counted as business days.
8+
- cron: '0 10 * * 1-5'
9+
10+
# pulls.* list + listRequestedReviewers → pull-requests: read
11+
# issues timeline + comments + createComment → issues: write
12+
# repos.listCollaborators (outside) → Metadata read on the token (see GitHub App permission map)
13+
# checkout → contents: read
14+
permissions:
15+
contents: read
16+
issues: write
17+
pull-requests: read
18+
19+
concurrency:
20+
group: ${{ github.workflow }}
21+
cancel-in-progress: false
22+
23+
jobs:
24+
remind-reviewers:
25+
# `schedule` has no `repository` on github.event; forks must be skipped only for workflow_dispatch.
26+
if: github.event_name == 'schedule' || github.event.repository.fork != true
27+
runs-on: ubuntu-latest
28+
steps:
29+
- name: Checkout repository
30+
uses: actions/checkout@v4
31+
32+
- name: Remind pending reviewers
33+
uses: actions/github-script@v7
34+
with:
35+
script: |
36+
const { default: run } = await import(
37+
`${process.env.GITHUB_WORKSPACE}/scripts/pr-review-reminder.mjs`
38+
);
39+
await run({ github, context, core });

scripts/pr-review-reminder.mjs

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
/**
2+
* PR Review Reminder script.
3+
*
4+
* Posts reminder comments on open PRs whose requested reviewers have not
5+
* responded within 2 business days. Re-nags every 2 business days thereafter
6+
* until the review is submitted (or the request is removed).
7+
*
8+
* @mentions are narrowed as follows:
9+
* - Individual users: not [outside collaborators](https://docs.github.com/en/organizations/managing-outside-collaborators)
10+
* on this repo (via `repos.listCollaborators` with `affiliation: outside` — repo-scoped, no extra token).
11+
* - Team reviewers: only the org team `team-javascript-sdks` (by slug).
12+
*
13+
* Business days exclude weekends and a small set of recurring public holidays
14+
* (same calendar date each year) for US, CA, and AT.
15+
*
16+
* Intended to be called from a GitHub Actions workflow via actions/github-script:
17+
*
18+
* const { default: run } = await import(
19+
* `${process.env.GITHUB_WORKSPACE}/scripts/pr-review-reminder.mjs`
20+
* );
21+
* await run({ github, context, core });
22+
*/
23+
24+
// Team @mentions only for this slug. Individuals are filtered using outside-collaborator list (see below).
25+
const SDK_TEAM_SLUG = 'team-javascript-sdks';
26+
27+
// ---------------------------------------------------------------------------
28+
// Outside collaborators (repo API — works with default GITHUB_TOKEN).
29+
// Org members with access via teams or default permissions are not listed here.
30+
// ---------------------------------------------------------------------------
31+
32+
async function loadOutsideCollaboratorLogins(github, owner, repo, core) {
33+
try {
34+
const users = await github.paginate(github.rest.repos.listCollaborators, {
35+
owner,
36+
repo,
37+
affiliation: 'outside',
38+
per_page: 100,
39+
});
40+
return new Set(users.map(u => u.login));
41+
} catch (e) {
42+
const status = e.response?.status;
43+
core.warning(
44+
`Could not list outside collaborators for ${owner}/${repo} (${status ? `HTTP ${status}` : 'no status'}): ${e.message}. ` +
45+
'Skipping @mentions for individual reviewers (team reminders unchanged).',
46+
);
47+
return null;
48+
}
49+
}
50+
51+
// ---------------------------------------------------------------------------
52+
// Recurring public holidays (month–day in UTC, same date every year).
53+
// A calendar day counts as a holiday if it appears in any country list.
54+
// ---------------------------------------------------------------------------
55+
56+
const RECURRING_PUBLIC_HOLIDAYS_AT = [
57+
'01-01',
58+
'01-06',
59+
'05-01',
60+
'08-15',
61+
'10-26',
62+
'11-01',
63+
'12-08',
64+
'12-24',
65+
'12-25',
66+
'12-26',
67+
'12-31',
68+
];
69+
70+
const RECURRING_PUBLIC_HOLIDAYS_CA = ['01-01', '07-01', '09-30', '11-11', '12-24', '12-25', '12-26', '12-31'];
71+
72+
const RECURRING_PUBLIC_HOLIDAYS_US = ['01-01', '06-19', '07-04', '11-11', '12-24', '12-25', '12-26', '12-31'];
73+
74+
const RECURRING_PUBLIC_HOLIDAY_MM_DD = new Set([
75+
...RECURRING_PUBLIC_HOLIDAYS_AT,
76+
...RECURRING_PUBLIC_HOLIDAYS_CA,
77+
...RECURRING_PUBLIC_HOLIDAYS_US,
78+
]);
79+
80+
function monthDayUTC(date) {
81+
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
82+
const d = String(date.getUTCDate()).padStart(2, '0');
83+
return `${m}-${d}`;
84+
}
85+
86+
// ---------------------------------------------------------------------------
87+
// Business-day counter.
88+
// Counts fully-elapsed business days (Mon–Fri, not a public holiday) between
89+
// requestedAt and now. "Fully elapsed" means the day has completely passed,
90+
// so today is not included — giving the reviewer the rest of today to respond.
91+
//
92+
// Example: review requested Friday → elapsed complete days include Sat, Sun,
93+
// Mon, Tue, … The first two business days are Mon and Tue, so the reminder
94+
// fires on Wednesday morning. That gives the reviewer all of Monday and
95+
// Tuesday to respond.
96+
// ---------------------------------------------------------------------------
97+
98+
function countElapsedBusinessDays(requestedAt, now) {
99+
// Walk from the day after the request up to (but not including) today.
100+
const start = new Date(requestedAt);
101+
start.setUTCHours(0, 0, 0, 0);
102+
start.setUTCDate(start.getUTCDate() + 1);
103+
104+
const todayUTC = new Date(now);
105+
todayUTC.setUTCHours(0, 0, 0, 0);
106+
107+
let count = 0;
108+
const cursor = new Date(start);
109+
while (cursor < todayUTC) {
110+
const dow = cursor.getUTCDay(); // 0 = Sun, 6 = Sat
111+
if (dow !== 0 && dow !== 6) {
112+
if (!RECURRING_PUBLIC_HOLIDAY_MM_DD.has(monthDayUTC(cursor))) {
113+
count++;
114+
}
115+
}
116+
cursor.setUTCDate(cursor.getUTCDate() + 1);
117+
}
118+
return count;
119+
}
120+
121+
// ---------------------------------------------------------------------------
122+
// Reminder marker helpers
123+
// ---------------------------------------------------------------------------
124+
125+
// Returns a unique HTML comment marker for a reviewer key (login or "team:slug").
126+
// Used for precise per-reviewer deduplication in existing comments.
127+
function reminderMarker(key) {
128+
return `<!-- review-reminder:${key} -->`;
129+
}
130+
131+
// ---------------------------------------------------------------------------
132+
// Main entry point
133+
// ---------------------------------------------------------------------------
134+
135+
export default async function run({ github, context, core }) {
136+
const { owner, repo } = context.repo;
137+
const now = new Date();
138+
139+
core.info(`Using ${RECURRING_PUBLIC_HOLIDAY_MM_DD.size} recurring public holiday month–day values (US/CA/AT union)`);
140+
141+
const outsideCollaboratorLogins = await loadOutsideCollaboratorLogins(github, owner, repo, core);
142+
if (outsideCollaboratorLogins) {
143+
core.info(`Excluding ${outsideCollaboratorLogins.size} outside collaborator login(s) from individual @mentions`);
144+
}
145+
146+
// ---------------------------------------------------------------------------
147+
// Main loop
148+
// ---------------------------------------------------------------------------
149+
150+
// Fetch all open PRs
151+
const prs = await github.paginate(github.rest.pulls.list, {
152+
owner,
153+
repo,
154+
state: 'open',
155+
per_page: 100,
156+
});
157+
158+
core.info(`Found ${prs.length} open PRs`);
159+
160+
for (const pr of prs) {
161+
// Skip draft PRs and PRs opened by bots
162+
if (pr.draft) continue;
163+
if (pr.user?.type === 'Bot') continue;
164+
165+
// Get currently requested reviewers (only those who haven't reviewed yet —
166+
// GitHub automatically removes a reviewer from this list once they submit a review)
167+
const { data: requested } = await github.rest.pulls.listRequestedReviewers({
168+
owner,
169+
repo,
170+
pull_number: pr.number,
171+
});
172+
173+
const pendingReviewers = requested.users; // individual users
174+
const pendingTeams = requested.teams; // team reviewers
175+
if (pendingReviewers.length === 0 && pendingTeams.length === 0) continue;
176+
177+
// Fetch the PR timeline to determine when each review was (last) requested
178+
const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, {
179+
owner,
180+
repo,
181+
issue_number: pr.number,
182+
per_page: 100,
183+
});
184+
185+
// Fetch existing comments so we can detect previous reminders
186+
const comments = await github.paginate(github.rest.issues.listComments, {
187+
owner,
188+
repo,
189+
issue_number: pr.number,
190+
per_page: 100,
191+
});
192+
193+
const botComments = comments.filter(c => c.user?.login === 'github-actions[bot]');
194+
195+
// Returns the date of the most recent reminder comment that contains the given marker,
196+
// or null if no such comment exists.
197+
function latestReminderDate(key) {
198+
const marker = reminderMarker(key);
199+
const matches = botComments
200+
.filter(c => c.body.includes(marker))
201+
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
202+
return matches.length > 0 ? new Date(matches[0].created_at) : null;
203+
}
204+
205+
// Returns true if a reminder is due for a reviewer/team:
206+
// - The "anchor" is the later of: the review-request date, or the last
207+
// reminder we already posted for this reviewer. This means the
208+
// 2-business-day clock restarts after every reminder (re-nagging), and
209+
// also resets when a new push re-requests the review.
210+
// - A reminder fires when ≥ 2 full business days have elapsed since the anchor.
211+
function needsReminder(requestedAt, key) {
212+
const lastReminded = latestReminderDate(key);
213+
const anchor = lastReminded && lastReminded > requestedAt ? lastReminded : requestedAt;
214+
return countElapsedBusinessDays(anchor, now) >= 2;
215+
}
216+
217+
// Collect overdue individual reviewers
218+
const toRemind = []; // { key, mention }
219+
220+
for (const reviewer of pendingReviewers) {
221+
const requestEvents = timeline
222+
.filter(e => e.event === 'review_requested' && e.requested_reviewer?.login === reviewer.login)
223+
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
224+
225+
if (requestEvents.length === 0) {
226+
core.warning(
227+
`PR #${pr.number}: pending reviewer @${reviewer.login} has no matching review_requested timeline event; skipping reminder for them.`,
228+
);
229+
continue;
230+
}
231+
232+
const requestedAt = new Date(requestEvents[0].created_at);
233+
if (!needsReminder(requestedAt, reviewer.login)) continue;
234+
235+
if (outsideCollaboratorLogins === null) {
236+
continue;
237+
}
238+
if (outsideCollaboratorLogins.has(reviewer.login)) {
239+
continue;
240+
}
241+
242+
toRemind.push({ key: reviewer.login, mention: `@${reviewer.login}` });
243+
}
244+
245+
// Collect overdue team reviewers
246+
for (const team of pendingTeams) {
247+
if (team.slug !== SDK_TEAM_SLUG) {
248+
continue;
249+
}
250+
251+
const requestEvents = timeline
252+
.filter(e => e.event === 'review_requested' && e.requested_team?.slug === team.slug)
253+
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
254+
255+
if (requestEvents.length === 0) {
256+
core.warning(
257+
`PR #${pr.number}: pending team reviewer @${owner}/${team.slug} has no matching review_requested timeline event; skipping reminder for them.`,
258+
);
259+
continue;
260+
}
261+
262+
const requestedAt = new Date(requestEvents[0].created_at);
263+
const key = `team:${team.slug}`;
264+
if (!needsReminder(requestedAt, key)) continue;
265+
266+
toRemind.push({ key, mention: `@${owner}/${team.slug}` });
267+
}
268+
269+
if (toRemind.length === 0) continue;
270+
271+
// Build a single comment that includes per-reviewer markers (for precise dedup
272+
// on subsequent runs) and @-mentions all overdue reviewers/teams.
273+
const markers = toRemind.map(({ key }) => reminderMarker(key)).join('\n');
274+
const mentions = toRemind.map(({ mention }) => mention).join(', ');
275+
const body = `${markers}\n👋 ${mentions} — Please review this PR when you get a chance!`;
276+
277+
await github.rest.issues.createComment({
278+
owner,
279+
repo,
280+
issue_number: pr.number,
281+
body,
282+
});
283+
284+
core.info(`Posted review reminder on PR #${pr.number} for: ${mentions}`);
285+
}
286+
}

0 commit comments

Comments
 (0)