Skip to content

Commit b08add0

Browse files
CopilotLms24
andauthored
refactor: extract review reminder script to scripts/pr-review-reminder.mjs
Agent-Logs-Url: https://github.com/getsentry/sentry-javascript/sessions/0e6a9f7f-93f3-4278-8456-15bfc8715e39 Co-authored-by: Lms24 <8420481+Lms24@users.noreply.github.com>
1 parent 1bae9fe commit b08add0

File tree

2 files changed

+240
-216
lines changed

2 files changed

+240
-216
lines changed

.github/workflows/pr-review-reminder.yml

Lines changed: 6 additions & 216 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)