diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml new file mode 100644 index 000000000000..3eda72221948 --- /dev/null +++ b/.github/workflows/pr-review-reminder.yml @@ -0,0 +1,39 @@ +name: 'PR: Review Reminder' + +on: + workflow_dispatch: + schedule: + # Run on weekdays at 10:00 AM UTC. No new reminders can fire on weekends because + # Saturday/Sunday are never counted as business days. + - cron: '0 10 * * 1-5' + +# pulls.* list + listRequestedReviewers → pull-requests: read +# issues timeline + comments + createComment → issues: write +# repos.listCollaborators (outside) → Metadata read on the token (see GitHub App permission map) +# checkout → contents: read +permissions: + contents: read + issues: write + pull-requests: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + remind-reviewers: + # `schedule` has no `repository` on github.event; forks must be skipped only for workflow_dispatch. + if: github.event_name == 'schedule' || github.event.repository.fork != true + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Remind pending reviewers + uses: actions/github-script@v7 + with: + script: | + const { default: run } = await import( + `${process.env.GITHUB_WORKSPACE}/scripts/pr-review-reminder.mjs` + ); + await run({ github, context, core }); diff --git a/scripts/pr-review-reminder.mjs b/scripts/pr-review-reminder.mjs new file mode 100644 index 000000000000..5e264e236598 --- /dev/null +++ b/scripts/pr-review-reminder.mjs @@ -0,0 +1,290 @@ +/** + * 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 the org team `team-javascript-sdks` (by slug). + * + * Business days exclude weekends and public holidays for US, CA, and AT + * (fetched at runtime from the Nager.Date API). + * + * 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 this slug. Individuals are filtered using outside-collaborator list (see below). +const SDK_TEAM_SLUG = 'team-javascript-sdks'; + +// --------------------------------------------------------------------------- +// 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; + } +} + +// --------------------------------------------------------------------------- +// Public holidays (US, Canada, Austria) via Nager.Date — free, no API key. +// See https://date.nager.at/ for documentation and supported countries. +// We fetch the current year and the previous year so that reviews requested +// in late December are handled correctly when the workflow runs in January. +// If the API is unreachable we fall back to weekday-only checking and warn. +// --------------------------------------------------------------------------- + +const COUNTRY_CODES = ['US', 'CA', 'AT']; + +async function fetchHolidaysForYear(year, core) { + const dates = new Set(); + for (const cc of COUNTRY_CODES) { + try { + const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${cc}`); + if (!resp.ok) { + core.warning(`Nager.Date returned ${resp.status} for ${cc}/${year}`); + continue; + } + const holidays = await resp.json(); + for (const h of holidays) { + dates.add(h.date); // 'YYYY-MM-DD' + } + } catch (e) { + core.warning(`Failed to fetch holidays for ${cc}/${year}: ${e.message}`); + } + } + return dates; +} + +// --------------------------------------------------------------------------- +// 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, publicHolidays) { + // 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) { + const dateStr = cursor.toISOString().slice(0, 10); + if (!publicHolidays.has(dateStr)) { + 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 ``; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +export default async function run({ github, context, core }) { + const { owner, repo } = context.repo; + const now = new Date(); + + // Fetch public holidays + const currentYear = now.getUTCFullYear(); + const [currentYearHolidays, previousYearHolidays] = await Promise.all([ + fetchHolidaysForYear(currentYear, core), + fetchHolidaysForYear(currentYear - 1, core), + ]); + const publicHolidays = new Set([...currentYearHolidays, ...previousYearHolidays]); + + core.info(`Loaded ${publicHolidays.size} public holiday dates for ${currentYear - 1}–${currentYear}`); + + 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; + + // 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, publicHolidays) >= 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 (team.slug !== SDK_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}`); + } +}