-
-
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
Open
Copilot
wants to merge
8
commits into
develop
Choose a base branch
from
copilot/add-review-reminder-workflow
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+325
−0
Open
Changes from all 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,286 @@ | ||
| /** | ||
| * 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 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 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; | ||
| } | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // 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; | ||
|
|
||
| // 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 (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.