-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
chore: Add PR review reminder workflow #20175
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 6 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
5407476
chore: add PR review reminder workflow
Copilot 994f774
improve pr-review-reminder: team reviewers, re-review reset, better d…
Copilot 04055c1
chore: business-day threshold + Nager.Date holiday API in review remi…
Copilot 1bae9fe
chore: add re-nagging every 2 business days to PR review reminder
Copilot b08add0
refactor: extract review reminder script to scripts/pr-review-reminde…
Copilot 91d8c62
ci: Harden PR review reminder workflow
Lms24 2d6c239
fix: use correct `users` property from listRequestedReviewers API res…
Copilot 91524ed
replace holiday API call with static list
Lms24 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 `<!-- review-reminder:${key} -->`; | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // 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.reviewers; // individual users | ||
| const pendingTeams = requested.teams; // team reviewers | ||
| if (pendingReviewers.length === 0 && pendingTeams.length === 0) continue; | ||
|
sentry[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| // 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}`); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.