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