diff --git a/.github/workflows/pr-review-status.yml b/.github/workflows/pr-review-status.yml new file mode 100644 index 0000000..6976584 --- /dev/null +++ b/.github/workflows/pr-review-status.yml @@ -0,0 +1,171 @@ +name: PR Review Status Update + +on: + pull_request_review: + types: [submitted] + +concurrency: + group: pr-review-status-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: read + +jobs: + update-status: + runs-on: ubuntu-latest + # Only act on real reviews, not PENDING (draft reviews) + if: >- + github.event.review.state == 'approved' || + github.event.review.state == 'changes_requested' + steps: + - name: Checkout bot-state branch + uses: actions/checkout@v4 + with: + ref: bot-state + path: bot-state + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Update Slack message and bot state + 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 rotationPath = path.join(BOT_STATE_DIR, 'rotation.json'); + const prsDir = path.join(BOT_STATE_DIR, 'prs'); + + const reviewState = context.payload.review.state; // "approved" or "changes_requested" + const reviewer = context.payload.review.user.login; + const prNumber = context.payload.pull_request.number; + const prUrl = context.payload.pull_request.html_url; + const prTitle = context.payload.pull_request.title; + + 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.'); + return; + } + const rotation = JSON.parse(fs.readFileSync(rotationPath, 'utf8')); + + // ── Find the per-PR file ──────────────────────────────────────── + const prFileKey = `${context.repo.owner}_${context.repo.repo}_${prNumber}`; + const prFilePath = path.join(prsDir, `${prFileKey}.json`); + + if (!fs.existsSync(prFilePath)) { + console.log(`No per-PR file for #${prNumber}. Not tracked by the bot.`); + return; + } + + const prData = JSON.parse(fs.readFileSync(prFilePath, 'utf8')); + + // Only care about the assigned main reviewer's reviews + const isMainReviewer = reviewer.toLowerCase() === prData.main_reviewer.toLowerCase(); + + // ── Determine emoji and label ──────────────────────────────────── + let emoji, label; + if (reviewState === 'approved') { + emoji = ':white_check_mark:'; + label = 'Approved'; + } else { + emoji = ':arrows_counterclockwise:'; + label = 'Changes Requested'; + } + + // ── Helper: call Slack API ─────────────────────────────────────── + async function slackApi(method, body) { + try { + const resp = await fetch(`https://slack.com/api/${method}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`, + }, + body: JSON.stringify(body), + }); + const data = await resp.json(); + if (!data.ok) console.log(`Slack ${method} error: ${data.error}`); + return data; + } catch (err) { + console.log(`Slack ${method} failed: ${err.message}`); + return { ok: false }; + } + } + + // ── Update the original Slack message with a status banner ─────── + if (prData.slack_thread_ts && isMainReviewer) { + await slackApi('chat.update', { + channel: rotation.slack_channel_id, + ts: prData.slack_thread_ts, + text: `${label}: <${prUrl}|#${prNumber} ${prTitle}>`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `${emoji} *${label}*\n*<${prUrl}|#${prNumber} ${prTitle}>*\nAuthor: *${prData.author}*`, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `:mag: *Main reviewer:* <@${prData.main_reviewer_slack}>\n:eyes: *Also reviewing:* <@${rotation.always_reviewer_slack}>`, + }, + }, + ], + }); + console.log(`Updated original Slack message for PR #${prNumber}`); + } + + // ── Post a thread reply for visibility ─────────────────────────── + if (prData.slack_thread_ts) { + const reviewerSlack = rotation.github_to_slack[reviewer]; + const mention = reviewerSlack ? `<@${reviewerSlack}>` : `*${reviewer}*`; + + await slackApi('chat.postMessage', { + channel: rotation.slack_channel_id, + thread_ts: prData.slack_thread_ts, + text: `${emoji} ${mention} ${reviewState === 'approved' ? 'approved' : 'requested changes on'} <${prData.pr_url}|PR #${prNumber}>`, + }); + } + + // ── Update bot state if this is the main reviewer ──────────────── + if (isMainReviewer && prData.status === 'open') { + prData.status = 'reviewed'; + fs.writeFileSync(prFilePath, JSON.stringify(prData, null, 2) + '\n'); + console.log(`Marked PR #${prNumber} as reviewed`); + } + + - 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: mark PR #${{ github.event.pull_request.number }} as reviewed" + + 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 diff --git a/.github/workflows/pr-reviewer-assign.yml b/.github/workflows/pr-reviewer-assign.yml new file mode 100644 index 0000000..4d0b174 --- /dev/null +++ b/.github/workflows/pr-reviewer-assign.yml @@ -0,0 +1,253 @@ +name: PR Reviewer Assignment + +on: + pull_request: + types: [opened, ready_for_review, reopened] + +concurrency: + group: pr-assign-rotation + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + assign-reviewer: + runs-on: ubuntu-latest + if: ${{ !github.event.pull_request.draft }} + steps: + - name: Checkout bot-state branch + uses: actions/checkout@v4 + with: + ref: bot-state + path: bot-state + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Assign reviewer and notify Slack + 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 rotationPath = path.join(BOT_STATE_DIR, 'rotation.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.'); + return; + } + const rotation = JSON.parse(fs.readFileSync(rotationPath, 'utf8')); + + // ── PR metadata ───────────────────────────────────────── + const pr = context.payload.pull_request; + const prNumber = pr.number; + const prAuthor = pr.user.login; + const prUrl = pr.html_url; + const prTitle = pr.title; + const repoFullName = `${context.repo.owner}/${context.repo.repo}`; + const prFileKey = `${context.repo.owner}_${context.repo.repo}_${prNumber}`; + const prFilePath = path.join(prsDir, `${prFileKey}.json`); + + // ── Ensure prs/ directory exists ───────────────────────── + if (!fs.existsSync(prsDir)) { + fs.mkdirSync(prsDir, { recursive: true }); + } + + // ── Helper: post a Slack message ───────────────────────── + async function postSlack(msg) { + 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(msg), + }); + const data = await resp.json(); + if (data.ok) { + console.log(`Slack message posted (ts: ${data.ts})`); + return data.ts; + } + console.log(`Slack error: ${data.error}`); + } catch (err) { + console.log(`Slack post failed: ${err.message}`); + } + return null; + } + + // ── Idempotency: PR already tracked ────────────────────── + if (fs.existsSync(prFilePath)) { + const existing = JSON.parse(fs.readFileSync(prFilePath, 'utf8')); + + if (existing.status === 'open') { + console.log(`PR #${prNumber} already has reviewer ${existing.main_reviewer}. Skipping.`); + return; + } + + // PR was closed/reviewed/merged but reopened — re-notify, keep same reviewer + console.log(`PR #${prNumber} was "${existing.status}", now reopened. Re-notifying.`); + existing.status = 'open'; + existing.last_reminded_at = null; + + try { + await github.rest.pulls.requestReviewers({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + reviewers: [existing.main_reviewer], + }); + } catch (err) { + console.log(`Warning: could not re-request review from ${existing.main_reviewer}: ${err.message}`); + } + + const ts = await postSlack({ + channel: rotation.slack_channel_id, + text: `PR reopened: <${prUrl}|#${prNumber} ${prTitle}>`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Reopened:* *<${prUrl}|#${prNumber} ${prTitle}>*\nAuthor: *${prAuthor}*`, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `:mag: *Main reviewer:* <@${existing.main_reviewer_slack}>\n:eyes: *Also reviewing:* <@${rotation.always_reviewer_slack}>`, + }, + }, + ], + }); + if (ts) existing.slack_thread_ts = ts; + + fs.writeFileSync(prFilePath, JSON.stringify(existing, null, 2) + '\n'); + return; + } + + // ── Round-robin: pick next reviewer ────────────────────── + const roster = rotation.roster; + let cursor = rotation.cursor || 0; + let mainReviewer = null; + let newCursor = cursor; + + for (let i = 0; i < roster.length; i++) { + const idx = (cursor + i) % roster.length; + const candidate = roster[idx]; + if (candidate.toLowerCase() !== prAuthor.toLowerCase()) { + mainReviewer = candidate; + newCursor = (idx + 1) % roster.length; + break; + } + } + + // 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 + ); + if (alwaysEntry) { + mainReviewer = alwaysEntry[0]; + } else { + core.setFailed('No eligible reviewer found and could not determine always-reviewer.'); + return; + } + newCursor = (cursor + 1) % roster.length; + } + + const mainReviewerSlack = rotation.github_to_slack[mainReviewer]; + if (!mainReviewerSlack) { + core.setFailed(`No Slack ID mapping found for reviewer "${mainReviewer}".`); + return; + } + + console.log(`Assigning ${mainReviewer} to PR #${prNumber} (author: ${prAuthor}, cursor: ${cursor} → ${newCursor})`); + + // ── Request review on GitHub ───────────────────────────── + try { + await github.rest.pulls.requestReviewers({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + reviewers: [mainReviewer], + }); + console.log(`Review requested from ${mainReviewer}`); + } catch (err) { + console.log(`Warning: could not request review from ${mainReviewer}: ${err.message}`); + } + + // ── Post Slack notification ────────────────────────────── + const slackTs = await postSlack({ + channel: rotation.slack_channel_id, + text: `New PR for review: <${prUrl}|#${prNumber} ${prTitle}>`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*<${prUrl}|#${prNumber} ${prTitle}>*\nAuthor: *${prAuthor}*`, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `:mag: *Main reviewer:* <@${mainReviewerSlack}>\n:eyes: *Also reviewing:* <@${rotation.always_reviewer_slack}>`, + }, + }, + ], + }); + + // ── Persist state ──────────────────────────────────────── + rotation.cursor = newCursor; + fs.writeFileSync(rotationPath, JSON.stringify(rotation, null, 2) + '\n'); + + const prData = { + pr_url: prUrl, + repo: repoFullName, + pr_number: prNumber, + author: prAuthor, + main_reviewer: mainReviewer, + main_reviewer_slack: mainReviewerSlack, + created_at: new Date().toISOString(), + last_reminded_at: null, + slack_thread_ts: slackTs, + status: 'open', + }; + fs.writeFileSync(prFilePath, JSON.stringify(prData, null, 2) + '\n'); + console.log(`State written: cursor=${newCursor}, file=${prFilePath}`); + + - 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: assign reviewer for PR #${{ github.event.pull_request.number }}" + + 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 diff --git a/.github/workflows/pr-reviewer-remind.yml b/.github/workflows/pr-reviewer-remind.yml new file mode 100644 index 0000000..aa597c4 --- /dev/null +++ b/.github/workflows/pr-reviewer-remind.yml @@ -0,0 +1,225 @@ +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: {} + +concurrency: + group: pr-reminders + cancel-in-progress: false + +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 rotationPath = path.join(BOT_STATE_DIR, 'rotation.json'); + const prsDir = path.join(BOT_STATE_DIR, 'prs'); + const REMINDER_INTERVAL_MS = 48 * 60 * 60 * 1000; // 48 hours + + // ── Load rotation config ───────────────────────────────── + if (!fs.existsSync(rotationPath)) { + console.log('rotation.json not found. Nothing to do.'); + return; + } + const rotation = JSON.parse(fs.readFileSync(rotationPath, '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; + + 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; + } + + 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; + fs.writeFileSync(filePath, JSON.stringify(prData, null, 2) + '\n'); + changed = true; + continue; + } + + // ── Check if main reviewer has submitted a review ────── + let hasReviewed = false; + try { + const reviews = await github.rest.pulls.listReviews({ + owner: prOwner, + repo: prRepo, + pull_number: prData.pr_number, + }); + hasReviewed = reviews.data.some( + r => + r.user.login.toLowerCase() === prData.main_reviewer.toLowerCase() && + ['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED'].includes(r.state) + ); + } catch (err) { + console.log(`Could not fetch reviews for PR #${prData.pr_number}: ${err.message}`); + } + + if (hasReviewed) { + console.log(`PR #${prData.pr_number}: ${prData.main_reviewer} has reviewed. Done.`); + prData.status = 'reviewed'; + fs.writeFileSync(filePath, JSON.stringify(prData, null, 2) + '\n'); + changed = true; + continue; + } + + // ── Check if reviewer is still requested ─────────────── + const isStillRequested = ghPr.requested_reviewers.some( + r => r.login.toLowerCase() === prData.main_reviewer.toLowerCase() + ); + if (!isStillRequested) { + console.log(`PR #${prData.pr_number}: ${prData.main_reviewer} is no longer requested. 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 ────────────────────────────────────── + console.log(`PR #${prData.pr_number}: sending reminder for ${prData.main_reviewer}`); + + const reminderMsg = { + channel: rotation.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 } : {}), + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: [ + `: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}>`, + ].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 diff --git a/infrastructure/github/README.md b/infrastructure/github/README.md index 866ca0a..745fea1 100644 --- a/infrastructure/github/README.md +++ b/infrastructure/github/README.md @@ -26,13 +26,18 @@ No modules. | [github_actions_secret.aws_secret_access_key](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/actions_secret) | resource | | [github_actions_secret.infisical_client_id](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/actions_secret) | resource | | [github_actions_secret.infisical_client_secret](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/actions_secret) | resource | +| [github_actions_secret.slack_bot_token](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/actions_secret) | resource | +| [github_branch.bot_state](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/branch) | resource | | [github_branch_default.main](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/branch_default) | resource | +| [github_branch_protection.bot_state](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/branch_protection) | resource | | [github_branch_protection.main](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/branch_protection) | resource | | [github_repository.branch](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository) | resource | | [github_repository_collaborator.collaborators](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_collaborator) | resource | +| [github_repository_file.rotation_json](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_file) | resource | | [infisical_secrets.aws_folder](https://registry.terraform.io/providers/infisical/infisical/latest/docs/data-sources/secrets) | data source | | [infisical_secrets.github_folder](https://registry.terraform.io/providers/infisical/infisical/latest/docs/data-sources/secrets) | data source | | [infisical_secrets.infisical_folder](https://registry.terraform.io/providers/infisical/infisical/latest/docs/data-sources/secrets) | data source | +| [infisical_secrets.slack_folder](https://registry.terraform.io/providers/infisical/infisical/latest/docs/data-sources/secrets) | data source | ## Inputs @@ -42,6 +47,10 @@ No modules. | [infisical\_client\_secret](#input\_infisical\_client\_secret) | n/a | `string` | n/a | yes | | [infisical\_workspace\_id](#input\_infisical\_workspace\_id) | n/a | `string` | `"d1ee8b80-118c-4daf-ae84-31da43261b76"` | no | | [repository\_collaborators](#input\_repository\_collaborators) | List of GitHub users to add as collaborators |
list(object({
username = string
permission = string
})) | `[]` | no |
+| [review\_bot\_always\_reviewer\_slack](#input\_review\_bot\_always\_reviewer\_slack) | Slack member ID of the person who reviews every PR | `string` | `"U07NGFM1QKE"` | no |
+| [review\_bot\_github\_to\_slack](#input\_review\_bot\_github\_to\_slack) | Map of GitHub username → Slack member ID (U0…) | `map(string)` | {
"Rayna-Yu": "U083UGSCU7P",
"bhuvanh66": "U084JKT1GG2",
"denniwang": "U07F8LM2X61",
"mehanana": "U084AMND8FK",
"nourshoreibah": "U07NGFM1QKE",
"saumyapalk23": "U09EYETUEGP",
"tsudhakar87": "U08NFFSJEG1"
} | no |
+| [review\_bot\_roster](#input\_review\_bot\_roster) | Ordered list of GitHub usernames for round-robin review assignment | `list(string)` | [| no | +| [review\_bot\_slack\_channel\_id](#input\_review\_bot\_slack\_channel\_id) | Slack channel ID where review notifications are posted | `string` | `"C09DGFG5JR4"` | no | ## Outputs diff --git a/infrastructure/github/review-bot.tf b/infrastructure/github/review-bot.tf new file mode 100644 index 0000000..5d19fbd --- /dev/null +++ b/infrastructure/github/review-bot.tf @@ -0,0 +1,51 @@ +# ────────────────────────────────────────────────────────────── +# PR Review Bot — bot-state branch, seed data, branch protection +# ────────────────────────────────────────────────────────────── + +# Branch that holds round-robin state (rotation.json, prs/*.json). +# Created from the default branch; after that it diverges and is +# managed exclusively by GitHub Actions. +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" { + repository = github_repository.branch.name + branch = github_branch.bot_state.branch + file = "rotation.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 + slack_channel_id = var.review_bot_slack_channel_id + timezone = "America/New_York" + }) + + commit_message = "chore(bot): initialize rotation state" + commit_author = "terraform" + commit_email = "terraform@noreply.github.com" + overwrite_on_create = false + + lifecycle { + ignore_changes = [content] + } +} + +# Lightweight protection: block force-pushes and branch deletion. +# No PR or approval rules — the bot needs to push freely. +resource "github_branch_protection" "bot_state" { + repository_id = github_repository.branch.node_id + pattern = "bot-state" + + allows_force_pushes = false + allows_deletions = false + enforce_admins = false +} diff --git a/infrastructure/github/secrets.tf b/infrastructure/github/secrets.tf index 8e4a9fc..d0ec554 100644 --- a/infrastructure/github/secrets.tf +++ b/infrastructure/github/secrets.tf @@ -16,12 +16,6 @@ data "infisical_secrets" "infisical_folder" { folder_path = "/infisical" } -data "infisical_secrets" "cognito_folder" { - env_slug = "dev" - workspace_id = var.infisical_workspace_id - folder_path = "/aws/cognito" -} - resource "github_actions_secret" "aws_access_key_id" { repository = github_repository.branch.name @@ -47,16 +41,18 @@ resource "github_actions_secret" "infisical_client_secret" { plaintext_value = data.infisical_secrets.infisical_folder.secrets["infisical-tf-client-secret"].value } -resource "github_actions_secret" "cognito_user_pool_id" { - repository = github_repository.branch.name - secret_name = "COGNITO_USER_POOL_ID" - plaintext_value = data.infisical_secrets.cognito_folder.secrets["COGNITO_USER_POOL_ID"].value +# ── PR Review Bot ──────────────────────────────────────────── + +data "infisical_secrets" "slack_folder" { + env_slug = "dev" + workspace_id = var.infisical_workspace_id + folder_path = "/slack" } -resource "github_actions_secret" "cognito_client_id" { +resource "github_actions_secret" "slack_bot_token" { repository = github_repository.branch.name - secret_name = "COGNITO_CLIENT_ID" - plaintext_value = data.infisical_secrets.cognito_folder.secrets["COGNITO_CLIENT_ID"].value + secret_name = "SLACK_BOT_TOKEN" + plaintext_value = data.infisical_secrets.slack_folder.secrets["slack-bot-token"].value } diff --git a/infrastructure/github/variables.tf b/infrastructure/github/variables.tf index e17d6a6..a74761d 100644 --- a/infrastructure/github/variables.tf +++ b/infrastructure/github/variables.tf @@ -10,4 +10,38 @@ variable "repository_collaborators" { # permission = "push" # } ] -} \ No newline at end of file +} + +# ── PR Review Bot ──────────────────────────────────────────── + +variable "review_bot_roster" { + description = "Ordered list of GitHub usernames for round-robin review assignment" + type = list(string) + default = ["denniwang", "bhuvanh66", "Rayna-Yu", "mehanana", "tsudhakar87", "saumyapalk23"] +} + +variable "review_bot_github_to_slack" { + description = "Map of GitHub username → Slack member ID (U0…)" + type = map(string) + default = { + "denniwang" = "U07F8LM2X61" + "bhuvanh66" = "U084JKT1GG2" + "Rayna-Yu" = "U083UGSCU7P" + "mehanana" = "U084AMND8FK" + "tsudhakar87" = "U08NFFSJEG1" + "saumyapalk23" = "U09EYETUEGP" + "nourshoreibah" = "U07NGFM1QKE" + } +} + +variable "review_bot_always_reviewer_slack" { + description = "Slack member ID of the person who reviews every PR" + type = string + default = "U07NGFM1QKE" +} + +variable "review_bot_slack_channel_id" { + description = "Slack channel ID where review notifications are posted" + type = string + default = "C09DGFG5JR4" +}
"denniwang",
"bhuvanh66",
"Rayna-Yu",
"mehanana",
"tsudhakar87",
"saumyapalk23"
]