Skip to content

PR Review Reminders #193

PR Review Reminders

PR Review Reminders #193

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