diff --git a/.github/workflows/pr-review-status.yml b/.github/workflows/pr-review-status.yml index 6976584..e799332 100644 --- a/.github/workflows/pr-review-status.yml +++ b/.github/workflows/pr-review-status.yml @@ -37,7 +37,7 @@ jobs: const path = require('path'); const BOT_STATE_DIR = 'bot-state'; - const rotationPath = path.join(BOT_STATE_DIR, 'rotation.json'); + const configPath = path.join(BOT_STATE_DIR, 'config.json'); const prsDir = path.join(BOT_STATE_DIR, 'prs'); const reviewState = context.payload.review.state; // "approved" or "changes_requested" @@ -48,12 +48,12 @@ jobs: console.log(`Review submitted: ${reviewer} → ${reviewState} on PR #${prNumber}`); - // ── Load rotation config (for Slack channel + always_reviewer) ── - if (!fs.existsSync(rotationPath)) { - console.log('rotation.json not found. Skipping.'); + // ── Load config (Terraform-managed) ────────────────────── + if (!fs.existsSync(configPath)) { + console.log('config.json not found. Skipping.'); return; } - const rotation = JSON.parse(fs.readFileSync(rotationPath, 'utf8')); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); // ── Find the per-PR file ──────────────────────────────────────── const prFileKey = `${context.repo.owner}_${context.repo.repo}_${prNumber}`; @@ -102,7 +102,7 @@ jobs: // ── Update the original Slack message with a status banner ─────── if (prData.slack_thread_ts && isMainReviewer) { await slackApi('chat.update', { - channel: rotation.slack_channel_id, + channel: config.slack_channel_id, ts: prData.slack_thread_ts, text: `${label}: <${prUrl}|#${prNumber} ${prTitle}>`, blocks: [ @@ -117,7 +117,7 @@ jobs: type: 'section', text: { type: 'mrkdwn', - text: `:mag: *Main reviewer:* <@${prData.main_reviewer_slack}>\n:eyes: *Also reviewing:* <@${rotation.always_reviewer_slack}>`, + text: `:mag: *Main reviewer:* <@${prData.main_reviewer_slack}>\n:eyes: *Also reviewing:* <@${config.always_reviewer_slack}>`, }, }, ], @@ -127,11 +127,11 @@ jobs: // ── Post a thread reply for visibility ─────────────────────────── if (prData.slack_thread_ts) { - const reviewerSlack = rotation.github_to_slack[reviewer]; + const reviewerSlack = config.github_to_slack[reviewer]; const mention = reviewerSlack ? `<@${reviewerSlack}>` : `*${reviewer}*`; await slackApi('chat.postMessage', { - channel: rotation.slack_channel_id, + channel: config.slack_channel_id, thread_ts: prData.slack_thread_ts, text: `${emoji} ${mention} ${reviewState === 'approved' ? 'approved' : 'requested changes on'} <${prData.pr_url}|PR #${prNumber}>`, }); @@ -140,6 +140,7 @@ jobs: // ── Update bot state if this is the main reviewer ──────────────── if (isMainReviewer && prData.status === 'open') { prData.status = 'reviewed'; + prData.resolved_at = new Date().toISOString(); fs.writeFileSync(prFilePath, JSON.stringify(prData, null, 2) + '\n'); console.log(`Marked PR #${prNumber} as reviewed`); } diff --git a/.github/workflows/pr-reviewer-assign.yml b/.github/workflows/pr-reviewer-assign.yml index 4d0b174..fcc5e20 100644 --- a/.github/workflows/pr-reviewer-assign.yml +++ b/.github/workflows/pr-reviewer-assign.yml @@ -34,15 +34,19 @@ jobs: const path = require('path'); const BOT_STATE_DIR = 'bot-state'; - const rotationPath = path.join(BOT_STATE_DIR, 'rotation.json'); + const configPath = path.join(BOT_STATE_DIR, 'config.json'); + const statePath = path.join(BOT_STATE_DIR, 'state.json'); const prsDir = path.join(BOT_STATE_DIR, 'prs'); - // ── Load rotation state ───────────────────────────────── - if (!fs.existsSync(rotationPath)) { - core.setFailed('rotation.json not found on bot-state branch. Run Terraform to initialize.'); + // ── Load config (Terraform-managed) and state (bot-managed) ── + if (!fs.existsSync(configPath)) { + core.setFailed('config.json not found on bot-state branch. Run Terraform to initialize.'); return; } - const rotation = JSON.parse(fs.readFileSync(rotationPath, 'utf8')); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + const state = fs.existsSync(statePath) + ? JSON.parse(fs.readFileSync(statePath, 'utf8')) + : { cursor: 0 }; // ── PR metadata ───────────────────────────────────────── const pr = context.payload.pull_request; @@ -108,7 +112,7 @@ jobs: } const ts = await postSlack({ - channel: rotation.slack_channel_id, + channel: config.slack_channel_id, text: `PR reopened: <${prUrl}|#${prNumber} ${prTitle}>`, blocks: [ { @@ -122,7 +126,7 @@ jobs: type: 'section', text: { type: 'mrkdwn', - text: `:mag: *Main reviewer:* <@${existing.main_reviewer_slack}>\n:eyes: *Also reviewing:* <@${rotation.always_reviewer_slack}>`, + text: `:mag: *Main reviewer:* <@${existing.main_reviewer_slack}>\n:eyes: *Also reviewing:* <@${config.always_reviewer_slack}>`, }, }, ], @@ -134,8 +138,8 @@ jobs: } // ── Round-robin: pick next reviewer ────────────────────── - const roster = rotation.roster; - let cursor = rotation.cursor || 0; + const roster = config.roster; + let cursor = state.cursor || 0; let mainReviewer = null; let newCursor = cursor; @@ -152,8 +156,8 @@ jobs: // Fallback: if the only person in the roster is the author, // assign the always-reviewer instead. if (!mainReviewer) { - const alwaysEntry = Object.entries(rotation.github_to_slack).find( - ([, slackId]) => slackId === rotation.always_reviewer_slack + const alwaysEntry = Object.entries(config.github_to_slack).find( + ([, slackId]) => slackId === config.always_reviewer_slack ); if (alwaysEntry) { mainReviewer = alwaysEntry[0]; @@ -164,7 +168,7 @@ jobs: newCursor = (cursor + 1) % roster.length; } - const mainReviewerSlack = rotation.github_to_slack[mainReviewer]; + const mainReviewerSlack = config.github_to_slack[mainReviewer]; if (!mainReviewerSlack) { core.setFailed(`No Slack ID mapping found for reviewer "${mainReviewer}".`); return; @@ -187,7 +191,7 @@ jobs: // ── Post Slack notification ────────────────────────────── const slackTs = await postSlack({ - channel: rotation.slack_channel_id, + channel: config.slack_channel_id, text: `New PR for review: <${prUrl}|#${prNumber} ${prTitle}>`, blocks: [ { @@ -201,15 +205,15 @@ jobs: type: 'section', text: { type: 'mrkdwn', - text: `:mag: *Main reviewer:* <@${mainReviewerSlack}>\n:eyes: *Also reviewing:* <@${rotation.always_reviewer_slack}>`, + text: `:mag: *Main reviewer:* <@${mainReviewerSlack}>\n:eyes: *Also reviewing:* <@${config.always_reviewer_slack}>`, }, }, ], }); - // ── Persist state ──────────────────────────────────────── - rotation.cursor = newCursor; - fs.writeFileSync(rotationPath, JSON.stringify(rotation, null, 2) + '\n'); + // ── Persist state (only state.json + per-PR file) ──────── + state.cursor = newCursor; + fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n'); const prData = { pr_url: prUrl, diff --git a/.github/workflows/pr-reviewer-remind.yml b/.github/workflows/pr-reviewer-remind.yml index aa597c4..c6e2bb3 100644 --- a/.github/workflows/pr-reviewer-remind.yml +++ b/.github/workflows/pr-reviewer-remind.yml @@ -36,16 +36,17 @@ jobs: const path = require('path'); const BOT_STATE_DIR = 'bot-state'; - const rotationPath = path.join(BOT_STATE_DIR, 'rotation.json'); + const configPath = path.join(BOT_STATE_DIR, 'config.json'); const prsDir = path.join(BOT_STATE_DIR, 'prs'); const REMINDER_INTERVAL_MS = 48 * 60 * 60 * 1000; // 48 hours + const CLEANUP_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days - // ── Load rotation config ───────────────────────────────── - if (!fs.existsSync(rotationPath)) { - console.log('rotation.json not found. Nothing to do.'); + // ── Load config (Terraform-managed) ────────────────────── + if (!fs.existsSync(configPath)) { + console.log('config.json not found. Nothing to do.'); return; } - const rotation = JSON.parse(fs.readFileSync(rotationPath, 'utf8')); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); // ── Enumerate tracked PRs ──────────────────────────────── if (!fs.existsSync(prsDir)) { @@ -61,6 +62,7 @@ jobs: const now = Date.now(); let changed = false; + const TERMINAL_STATUSES = ['merged', 'closed', 'reviewed']; for (const file of prFiles) { const filePath = path.join(prsDir, file); @@ -72,6 +74,24 @@ jobs: 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; } @@ -96,6 +116,7 @@ jobs: 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; @@ -121,6 +142,7 @@ jobs: if (hasReviewed) { console.log(`PR #${prData.pr_number}: ${prData.main_reviewer} has reviewed. Done.`); prData.status = 'reviewed'; + prData.resolved_at = new Date().toISOString(); fs.writeFileSync(filePath, JSON.stringify(prData, null, 2) + '\n'); changed = true; continue; @@ -150,7 +172,7 @@ jobs: console.log(`PR #${prData.pr_number}: sending reminder for ${prData.main_reviewer}`); const reminderMsg = { - channel: rotation.slack_channel_id, + channel: config.slack_channel_id, text: `Reminder: PR #${prData.pr_number} awaits review from ${prData.main_reviewer}`, // Thread under the original assignment message when possible ...(prData.slack_thread_ts ? { thread_ts: prData.slack_thread_ts } : {}), @@ -163,7 +185,7 @@ jobs: `:bell: *Reminder:* <${prData.pr_url}|PR #${prData.pr_number}> is still awaiting review.`, ``, `:mag: <@${prData.main_reviewer_slack}> — please review when you get a chance.`, - `:eyes: cc <@${rotation.always_reviewer_slack}>`, + `:eyes: cc <@${config.always_reviewer_slack}>`, ].join('\n'), }, }, diff --git a/infrastructure/github/review-bot.tf b/infrastructure/github/review-bot.tf index 5d19fbd..2e65889 100644 --- a/infrastructure/github/review-bot.tf +++ b/infrastructure/github/review-bot.tf @@ -2,26 +2,25 @@ # PR Review Bot — bot-state branch, seed data, branch protection # ────────────────────────────────────────────────────────────── -# Branch that holds round-robin state (rotation.json, prs/*.json). +# Branch that holds round-robin state (config.json, state.json, prs/*.json). # Created from the default branch; after that it diverges and is -# managed exclusively by GitHub Actions. +# managed by Terraform (config) and GitHub Actions (state). resource "github_branch" "bot_state" { repository = github_repository.branch.name branch = "bot-state" source_branch = github_branch_default.main.branch } -# Seed rotation.json so the assign workflow can run immediately. -# After the first apply the bot updates this file on every PR; -# lifecycle ignore_changes keeps Terraform from reverting those edits. -resource "github_repository_file" "rotation_json" { +# config.json — Terraform is the source of truth. +# Roster, Slack mappings, channel ID, etc. live here. +# Change a variable in Terraform, apply, and it takes effect immediately. +resource "github_repository_file" "bot_config_json" { repository = github_repository.branch.name branch = github_branch.bot_state.branch - file = "rotation.json" + file = "config.json" content = jsonencode({ version = 1 - cursor = 0 roster = var.review_bot_roster github_to_slack = var.review_bot_github_to_slack always_reviewer_slack = var.review_bot_always_reviewer_slack @@ -29,6 +28,23 @@ resource "github_repository_file" "rotation_json" { timezone = "America/New_York" }) + commit_message = "chore(bot): update review bot config" + commit_author = "terraform" + commit_email = "terraform@noreply.github.com" + overwrite_on_create = true +} + +# state.json — Bot owns this (just the cursor). +# Seeded once by Terraform; ignored after that. +resource "github_repository_file" "bot_state_json" { + repository = github_repository.branch.name + branch = github_branch.bot_state.branch + file = "state.json" + + content = jsonencode({ + cursor = 0 + }) + commit_message = "chore(bot): initialize rotation state" commit_author = "terraform" commit_email = "terraform@noreply.github.com"