@@ -19,224 +19,14 @@ jobs:
1919 remind-reviewers :
2020 runs-on : ubuntu-latest
2121 steps :
22+ - name : Checkout repository
23+ uses : actions/checkout@v4
24+
2225 - name : Remind pending reviewers
2326 uses : actions/github-script@v7
2427 with :
2528 script : |
26- const { owner, repo } = context.repo;
27- const now = new Date();
28-
29- // ---------------------------------------------------------------------------
30- // Public holidays (US, Canada, Austria) via Nager.Date — free, no API key.
31- // See https://date.nager.at/ for documentation and supported countries.
32- // We fetch the current year and the previous year so that reviews requested
33- // in late December are handled correctly when the workflow runs in January.
34- // If the API is unreachable we fall back to weekday-only checking and warn.
35- // ---------------------------------------------------------------------------
36- const COUNTRY_CODES = ['US', 'CA', 'AT'];
37-
38- async function fetchHolidaysForYear(year) {
39- const dates = new Set();
40- for (const cc of COUNTRY_CODES) {
41- try {
42- const resp = await fetch(
43- `https://date.nager.at/api/v3/PublicHolidays/${year}/${cc}`,
44- );
45- if (!resp.ok) {
46- core.warning(`Nager.Date returned ${resp.status} for ${cc}/${year}`);
47- continue;
48- }
49- const holidays = await resp.json();
50- for (const h of holidays) {
51- dates.add(h.date); // 'YYYY-MM-DD'
52- }
53- } catch (e) {
54- core.warning(`Failed to fetch holidays for ${cc}/${year}: ${e.message}`);
55- }
56- }
57- return dates;
58- }
59-
60- const currentYear = now.getUTCFullYear();
61- const [currentYearHolidays, previousYearHolidays] = await Promise.all([
62- fetchHolidaysForYear(currentYear),
63- fetchHolidaysForYear(currentYear - 1),
64- ]);
65- const publicHolidays = new Set([...currentYearHolidays, ...previousYearHolidays]);
66-
67- core.info(
68- `Loaded ${publicHolidays.size} public holiday dates for ${currentYear - 1}–${currentYear}`,
29+ const { default: run } = await import(
30+ `${process.env.GITHUB_WORKSPACE}/scripts/pr-review-reminder.mjs`
6931 );
70-
71- // ---------------------------------------------------------------------------
72- // Business-day counter.
73- // Counts fully-elapsed business days (Mon–Fri, not a public holiday) between
74- // requestedAt and now. "Fully elapsed" means the day has completely passed,
75- // so today is not included — giving the reviewer the rest of today to respond.
76- //
77- // Example: review requested Friday → elapsed complete days include Sat, Sun,
78- // Mon, Tue, … The first two business days are Mon and Tue, so the reminder
79- // fires on Wednesday morning. That gives the reviewer all of Monday and
80- // Tuesday to respond.
81- // ---------------------------------------------------------------------------
82- function countElapsedBusinessDays(requestedAt) {
83- // Walk from the day after the request up to (but not including) today.
84- const start = new Date(requestedAt);
85- start.setUTCHours(0, 0, 0, 0);
86- start.setUTCDate(start.getUTCDate() + 1);
87-
88- const todayUTC = new Date(now);
89- todayUTC.setUTCHours(0, 0, 0, 0);
90-
91- let count = 0;
92- const cursor = new Date(start);
93- while (cursor < todayUTC) {
94- const dow = cursor.getUTCDay(); // 0 = Sun, 6 = Sat
95- if (dow !== 0 && dow !== 6) {
96- const dateStr = cursor.toISOString().slice(0, 10);
97- if (!publicHolidays.has(dateStr)) {
98- count++;
99- }
100- }
101- cursor.setUTCDate(cursor.getUTCDate() + 1);
102- }
103- return count;
104- }
105-
106- // ---------------------------------------------------------------------------
107- // Reminder marker helpers
108- // ---------------------------------------------------------------------------
109-
110- // Returns a unique HTML comment marker for a reviewer key (login or "team:slug").
111- // Used for precise per-reviewer deduplication in existing comments.
112- function reminderMarker(key) {
113- return `<!-- review-reminder:${key} -->`;
114- }
115-
116- // ---------------------------------------------------------------------------
117- // Main loop
118- // ---------------------------------------------------------------------------
119-
120- // Fetch all open PRs
121- const prs = await github.paginate(github.rest.pulls.list, {
122- owner,
123- repo,
124- state: 'open',
125- per_page: 100,
126- });
127-
128- core.info(`Found ${prs.length} open PRs`);
129-
130- for (const pr of prs) {
131- // Skip draft PRs and PRs opened by bots
132- if (pr.draft) continue;
133- if (pr.user?.type === 'Bot') continue;
134-
135- // Get currently requested reviewers (only those who haven't reviewed yet —
136- // GitHub automatically removes a reviewer from this list once they submit a review)
137- const { data: requested } = await github.rest.pulls.listRequestedReviewers({
138- owner,
139- repo,
140- pull_number: pr.number,
141- });
142-
143- const pendingReviewers = requested.reviewers; // individual users
144- const pendingTeams = requested.teams; // team reviewers
145- if (pendingReviewers.length === 0 && pendingTeams.length === 0) continue;
146-
147- // Fetch the PR timeline to determine when each review was (last) requested
148- const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, {
149- owner,
150- repo,
151- issue_number: pr.number,
152- per_page: 100,
153- });
154-
155- // Fetch existing comments so we can suppress duplicate reminders
156- const comments = await github.paginate(github.rest.issues.listComments, {
157- owner,
158- repo,
159- issue_number: pr.number,
160- per_page: 100,
161- });
162-
163- const botComments = comments.filter(c => c.user?.login === 'github-actions[bot]');
164-
165- // Returns the date of the most recent reminder comment that contains the given marker,
166- // or null if no such comment exists.
167- function latestReminderDate(key) {
168- const marker = reminderMarker(key);
169- const matches = botComments
170- .filter(c => c.body.includes(marker))
171- .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
172- return matches.length > 0 ? new Date(matches[0].created_at) : null;
173- }
174-
175- // Returns true if a reminder is due for a reviewer/team:
176- // - The "anchor" is the later of: the review-request date, or the last
177- // reminder we already posted for this reviewer. This means the
178- // 2-business-day clock restarts after every reminder (re-nagging), and
179- // also resets when a new push re-requests the review.
180- // - A reminder fires when ≥ 2 full business days have elapsed since the anchor.
181- function needsReminder(requestedAt, key) {
182- const lastReminded = latestReminderDate(key);
183- const anchor =
184- lastReminded && lastReminded > requestedAt ? lastReminded : requestedAt;
185- return countElapsedBusinessDays(anchor) >= 2;
186- }
187-
188- // Collect overdue individual reviewers
189- const toRemind = []; // { key, mention }
190-
191- for (const reviewer of pendingReviewers) {
192- const requestEvents = timeline
193- .filter(
194- e =>
195- e.event === 'review_requested' &&
196- e.requested_reviewer?.login === reviewer.login,
197- )
198- .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
199-
200- if (requestEvents.length === 0) continue;
201-
202- const requestedAt = new Date(requestEvents[0].created_at);
203- if (!needsReminder(requestedAt, reviewer.login)) continue;
204-
205- toRemind.push({ key: reviewer.login, mention: `@${reviewer.login}` });
206- }
207-
208- // Collect overdue team reviewers
209- for (const team of pendingTeams) {
210- const requestEvents = timeline
211- .filter(
212- e =>
213- e.event === 'review_requested' && e.requested_team?.slug === team.slug,
214- )
215- .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
216-
217- if (requestEvents.length === 0) continue;
218-
219- const requestedAt = new Date(requestEvents[0].created_at);
220- const key = `team:${team.slug}`;
221- if (!needsReminder(requestedAt, key)) continue;
222-
223- toRemind.push({ key, mention: `@${owner}/${team.slug}` });
224- }
225-
226- if (toRemind.length === 0) continue;
227-
228- // Build a single comment that includes per-reviewer markers (for precise dedup
229- // on subsequent runs) and @-mentions all overdue reviewers/teams.
230- const markers = toRemind.map(({ key }) => reminderMarker(key)).join('\n');
231- const mentions = toRemind.map(({ mention }) => mention).join(', ');
232- const body = `${markers}\n👋 ${mentions} — a friendly reminder that your review on this PR is still pending. Could you please take a look when you get a chance? Thank you!`;
233-
234- await github.rest.issues.createComment({
235- owner,
236- repo,
237- issue_number: pr.number,
238- body,
239- });
240-
241- core.info(`Posted review reminder on PR #${pr.number} for: ${mentions}`);
242- }
32+ await run({ github, context, core });
0 commit comments