Skip to content
Merged

fix #124

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions .github/workflows/pr-review-status.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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}`;
Expand Down Expand Up @@ -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: [
Expand All @@ -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}>`,
},
},
],
Expand All @@ -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}>`,
});
Expand All @@ -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`);
}
Expand Down
38 changes: 21 additions & 17 deletions .github/workflows/pr-reviewer-assign.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: [
{
Expand All @@ -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}>`,
},
},
],
Expand All @@ -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;

Expand All @@ -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];
Expand All @@ -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;
Expand All @@ -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: [
{
Expand All @@ -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,
Expand Down
36 changes: 29 additions & 7 deletions .github/workflows/pr-reviewer-remind.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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);
Expand All @@ -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;
}
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 } : {}),
Expand All @@ -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'),
},
},
Expand Down
32 changes: 24 additions & 8 deletions infrastructure/github/review-bot.tf
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,49 @@
# 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
slack_channel_id = var.review_bot_slack_channel_id
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"
Expand Down
Loading