PR Review Reminders #193
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
| name: PR Review Reminders | |
| on: | |
| schedule: | |
| # Every 6 hours — frequent enough to catch 48-hour windows, | |
| # infrequent enough to avoid noise. | |
| - cron: '0 */6 * * *' | |
| workflow_dispatch: {} | |
| permissions: | |
| contents: write | |
| pull-requests: read | |
| jobs: | |
| check-and-remind: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout bot-state branch | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: bot-state | |
| path: bot-state | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Check PRs and send reminders | |
| uses: actions/github-script@v7 | |
| env: | |
| SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const BOT_STATE_DIR = 'bot-state'; | |
| const configPath = path.join(BOT_STATE_DIR, 'config.json'); | |
| const prsDir = path.join(BOT_STATE_DIR, 'prs'); | |
| const REMINDER_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours | |
| const CLEANUP_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days | |
| // ── Load config (Terraform-managed) ────────────────────── | |
| if (!fs.existsSync(configPath)) { | |
| console.log('config.json not found. Nothing to do.'); | |
| return; | |
| } | |
| const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); | |
| // ── Enumerate tracked PRs ──────────────────────────────── | |
| if (!fs.existsSync(prsDir)) { | |
| console.log('No prs/ directory yet. Nothing to do.'); | |
| return; | |
| } | |
| const prFiles = fs.readdirSync(prsDir).filter(f => f.endsWith('.json')); | |
| if (prFiles.length === 0) { | |
| console.log('No tracked PRs. Nothing to do.'); | |
| return; | |
| } | |
| const now = Date.now(); | |
| let changed = false; | |
| const TERMINAL_STATUSES = ['merged', 'closed', 'reviewed']; | |
| for (const file of prFiles) { | |
| const filePath = path.join(prsDir, file); | |
| let prData; | |
| try { | |
| prData = JSON.parse(fs.readFileSync(filePath, 'utf8')); | |
| } catch (err) { | |
| console.log(`Skipping malformed file ${file}: ${err.message}`); | |
| continue; | |
| } | |
| // ── Cleanup: delete files in terminal state for 7+ days ── | |
| if (TERMINAL_STATUSES.includes(prData.status)) { | |
| if (prData.resolved_at) { | |
| const age = now - new Date(prData.resolved_at).getTime(); | |
| if (age >= CLEANUP_AGE_MS) { | |
| console.log(`Cleanup: deleting ${file} (resolved ${(age / 86_400_000).toFixed(1)}d ago)`); | |
| fs.unlinkSync(filePath); | |
| changed = true; | |
| } | |
| } else { | |
| // Backfill resolved_at for files that were marked before this change | |
| prData.resolved_at = new Date().toISOString(); | |
| fs.writeFileSync(filePath, JSON.stringify(prData, null, 2) + '\n'); | |
| changed = true; | |
| } | |
| continue; | |
| } | |
| if (prData.status !== 'open') { | |
| continue; | |
| } | |
| // ── Fetch current PR state from GitHub ───────────────── | |
| const [prOwner, prRepo] = prData.repo.split('/'); | |
| let ghPr; | |
| try { | |
| const resp = await github.rest.pulls.get({ | |
| owner: prOwner, | |
| repo: prRepo, | |
| pull_number: prData.pr_number, | |
| }); | |
| ghPr = resp.data; | |
| } catch (err) { | |
| console.log(`Could not fetch PR #${prData.pr_number}: ${err.message}`); | |
| continue; | |
| } | |
| // ── If PR is closed or merged, mark and move on ──────── | |
| if (ghPr.state !== 'open') { | |
| const newStatus = ghPr.merged_at ? 'merged' : 'closed'; | |
| console.log(`PR #${prData.pr_number} is ${newStatus}.`); | |
| prData.status = newStatus; | |
| prData.resolved_at = new Date().toISOString(); | |
| fs.writeFileSync(filePath, JSON.stringify(prData, null, 2) + '\n'); | |
| changed = true; | |
| continue; | |
| } | |
| // ── Check if reviewers have submitted reviews ────────── | |
| let reviews = []; | |
| try { | |
| const resp = await github.rest.pulls.listReviews({ | |
| owner: prOwner, | |
| repo: prRepo, | |
| pull_number: prData.pr_number, | |
| }); | |
| reviews = resp.data; | |
| } catch (err) { | |
| console.log(`Could not fetch reviews for PR #${prData.pr_number}: ${err.message}`); | |
| } | |
| const REVIEW_STATES = ['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED']; | |
| const hasReviewed = (login) => reviews.some( | |
| r => r.user.login.toLowerCase() === login.toLowerCase() && REVIEW_STATES.includes(r.state) | |
| ); | |
| const mainDone = hasReviewed(prData.main_reviewer); | |
| const secondDone = !prData.second_reviewer || hasReviewed(prData.second_reviewer); | |
| if (mainDone && secondDone) { | |
| console.log(`PR #${prData.pr_number}: all assigned reviewers have reviewed. Done.`); | |
| prData.status = 'reviewed'; | |
| prData.resolved_at = new Date().toISOString(); | |
| fs.writeFileSync(filePath, JSON.stringify(prData, null, 2) + '\n'); | |
| changed = true; | |
| continue; | |
| } | |
| // ── Check if reviewers are still requested ───────────── | |
| const requestedLogins = ghPr.requested_reviewers.map(r => r.login.toLowerCase()); | |
| const pendingReviewers = []; | |
| if (!mainDone && requestedLogins.includes(prData.main_reviewer.toLowerCase())) { | |
| pendingReviewers.push({ login: prData.main_reviewer, slack: prData.main_reviewer_slack }); | |
| } | |
| if (prData.second_reviewer && !secondDone && requestedLogins.includes(prData.second_reviewer.toLowerCase())) { | |
| pendingReviewers.push({ login: prData.second_reviewer, slack: prData.second_reviewer_slack }); | |
| } | |
| if (pendingReviewers.length === 0) { | |
| console.log(`PR #${prData.pr_number}: no pending requested reviewers. Skipping reminder.`); | |
| continue; | |
| } | |
| // ── Should we remind? (48h since last touch) ─────────── | |
| const referenceTime = prData.last_reminded_at | |
| ? new Date(prData.last_reminded_at).getTime() | |
| : new Date(prData.created_at).getTime(); | |
| if (now - referenceTime < REMINDER_INTERVAL_MS) { | |
| const hoursLeft = ((REMINDER_INTERVAL_MS - (now - referenceTime)) / 3_600_000).toFixed(1); | |
| console.log(`PR #${prData.pr_number}: next reminder in ~${hoursLeft}h.`); | |
| continue; | |
| } | |
| // ── Send reminder ────────────────────────────────────── | |
| const pendingNames = pendingReviewers.map(r => r.login).join(', '); | |
| console.log(`PR #${prData.pr_number}: sending reminder for ${pendingNames}`); | |
| const mentionLines = pendingReviewers.map( | |
| r => `:mag: <@${r.slack}> — please review when you get a chance.` | |
| ); | |
| // Only cc the always-reviewer if they aren't the PR author | |
| // (if they are the author, they're not reviewing this one) | |
| const authorSlackId = config.github_to_slack[prData.author]; | |
| if (authorSlackId !== config.always_reviewer_slack) { | |
| mentionLines.push(`:eyes: cc <@${config.always_reviewer_slack}>`); | |
| } | |
| const reminderMsg = { | |
| channel: config.slack_channel_id, | |
| text: `Reminder: PR #${prData.pr_number} awaits review from ${pendingNames}`, | |
| // Thread under the original assignment message when possible | |
| ...(prData.slack_thread_ts ? { thread_ts: prData.slack_thread_ts } : {}), | |
| blocks: [ | |
| { | |
| type: 'section', | |
| text: { | |
| type: 'mrkdwn', | |
| text: [ | |
| `:bell: *Reminder:* <${prData.pr_url}|PR #${prData.pr_number}> is still awaiting review.`, | |
| ``, | |
| ...mentionLines, | |
| ].join('\n'), | |
| }, | |
| }, | |
| ], | |
| }; | |
| try { | |
| const resp = await fetch('https://slack.com/api/chat.postMessage', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`, | |
| }, | |
| body: JSON.stringify(reminderMsg), | |
| }); | |
| const data = await resp.json(); | |
| if (data.ok) { | |
| console.log(`Reminder sent for PR #${prData.pr_number}`); | |
| } else { | |
| console.log(`Slack error for PR #${prData.pr_number}: ${data.error}`); | |
| } | |
| } catch (err) { | |
| console.log(`Slack post failed for PR #${prData.pr_number}: ${err.message}`); | |
| } | |
| prData.last_reminded_at = new Date().toISOString(); | |
| fs.writeFileSync(filePath, JSON.stringify(prData, null, 2) + '\n'); | |
| changed = true; | |
| } | |
| if (!changed) { | |
| console.log('No state changes to commit.'); | |
| } | |
| - name: Commit and push bot-state | |
| working-directory: bot-state | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git add -A | |
| if git diff --cached --quiet; then | |
| echo "No changes to commit" | |
| exit 0 | |
| fi | |
| git commit -m "bot: update PR review state [$(date -u +%Y-%m-%dT%H:%M:%SZ)]" | |
| for attempt in 1 2 3; do | |
| if git pull --rebase origin bot-state && git push origin bot-state; then | |
| echo "Push succeeded (attempt $attempt)" | |
| exit 0 | |
| fi | |
| echo "Push attempt $attempt failed, retrying in 2s…" | |
| sleep 2 | |
| done | |
| echo "::error::Failed to push bot-state after 3 attempts" | |
| exit 1 |