diff --git a/.github/pr-review-fallback/pr-review-fallback.mjs b/.github/pr-review-fallback/pr-review-fallback.mjs new file mode 100644 index 0000000..ff9df25 --- /dev/null +++ b/.github/pr-review-fallback/pr-review-fallback.mjs @@ -0,0 +1,787 @@ +#!/usr/bin/env node +/** + * Portable CodeRabbit → Codex fallback reviewer. + * + * Request a Codex review only when CodeRabbit cannot review a ready PR. + * Copy this directory and the sibling workflow into other repos as-is. + * Override values at the top of `.github/workflows/coderabbit-fallback-review.yml` + * or via the environment variables listed in DEFAULTS. + * + * Codex trigger (verified 2026-10-06 against https://learn.chatgpt.com/docs/third-party/github): + * post a top-level PR comment whose text includes `@codex review`. + * Codex maps the comment author to a ChatGPT-connected GitHub account, so + * `github-actions[bot]` / GITHUB_TOKEN mentions are not a reliable trigger. + * Set repo secret CODEX_GITHUB_TOKEN to a PAT for the connected user. + * If that secret is missing, skip posting, emit a warning, and exit 0. + */ + +import { appendFileSync } from "node:fs"; + +export const DEFAULTS = { + windowMinutes: 45, + maxPrsPerRun: 2, + label: "review-fallback-coderabbit-unavailable", + coderabbitLogins: ["coderabbitai[bot]", "coderabbitai"], + dependencyBotLogins: [ + "dependabot[bot]", + "dependabot", + "renovate[bot]", + "renovate", + ], + markerName: "pr-review-fallback", + markerVersion: "v1", + codexTrigger: "@codex review", + githubApiVersion: "2022-11-28", +}; + +const RATE_LIMIT_MARKERS = [ + /rate limited by coderabbit\.ai/i, + /## Review limit reached/i, + /\bReview rate limited\b/i, + /\bReview rate limit exceeded\b/i, + /\byou've reached your PR review limit\b/i, + /\bRate limit exceeded\b/i, +]; + +const ERROR_MARKERS = [ + /failure by coderabbit\.ai/i, + /## Review failed/i, + /failed to (start|complete) (the )?review/i, + /unable to (start|complete) (the )?review/i, + /encountered an error (while|during) (this )?review/i, +]; + +const DRAFT_SKIP_MARKERS = [ + /skip review by coderabbit\.ai/i, + /## Draft PR not reviewed/i, +]; + +const COMPLETED_REVIEW_MARKERS = [ + //, + /Actionable comments posted:/i, + //, + /final_review_risk_coverage/, +]; + +const EXPLICIT_REVIEW_REQUEST = + /@coderabbitai\s+(?:full\s+)?review\b/i; + +const COMMIT_SHA = /\b[0-9a-f]{40}\b/gi; +const COVERED_COMMIT = /"coveredCommitId"\s*:\s*"([0-9a-f]{40})"/i; +const BETWEEN_COMMITS = + /between\s+[0-9a-f]{7,40}\s+and\s+([0-9a-f]{7,40})/i; + +export function readConfig(env = process.env) { + const windowMinutes = positiveInt( + env.FALLBACK_WINDOW_MINUTES, + DEFAULTS.windowMinutes, + ); + const maxPrsPerRun = positiveInt( + env.FALLBACK_MAX_PRS_PER_RUN, + DEFAULTS.maxPrsPerRun, + ); + return { + windowMinutes, + windowMs: windowMinutes * 60 * 1000, + maxPrsPerRun, + label: env.FALLBACK_LABEL || DEFAULTS.label, + coderabbitLogins: parseLoginList( + env.FALLBACK_CODERABBIT_LOGINS, + DEFAULTS.coderabbitLogins, + ), + dependencyBotLogins: parseLoginList( + env.FALLBACK_DEPENDENCY_BOT_LOGINS, + DEFAULTS.dependencyBotLogins, + ), + markerName: DEFAULTS.markerName, + markerVersion: DEFAULTS.markerVersion, + codexTrigger: env.FALLBACK_CODEX_TRIGGER || DEFAULTS.codexTrigger, + ownerRepo: env.GITHUB_REPOSITORY || "", + commentToken: env.CODEX_GITHUB_TOKEN || "", + apiToken: env.GITHUB_TOKEN || env.CODEX_GITHUB_TOKEN || "", + apiBase: (env.GITHUB_API_URL || "https://api.github.com").replace(/\/$/, ""), + dryRun: parseBoolean(env.FALLBACK_DRY_RUN, false), + prNumber: env.FALLBACK_PR_NUMBER ? Number(env.FALLBACK_PR_NUMBER) : null, + eventName: env.GITHUB_EVENT_NAME || "", + commentLogin: env.FALLBACK_COMMENT_LOGIN || "", + commentBody: env.FALLBACK_COMMENT_BODY || "", + nowMs: env.FALLBACK_NOW_MS ? Number(env.FALLBACK_NOW_MS) : Date.now(), + }; +} + +function positiveInt(value, fallback) { + const parsed = Number.parseInt(String(value ?? ""), 10); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function parseBoolean(value, fallback) { + if (value == null || value === "") return fallback; + const normalized = String(value).trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + return fallback; +} + +function parseLoginList(value, fallback) { + if (!value) return fallback; + const items = String(value) + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + return items.length > 0 ? items : fallback; +} + +function loginSet(logins) { + return new Set(logins.map((login) => login.toLowerCase())); +} + +export function isDependencyBot(login, config = DEFAULTS) { + if (!login) return false; + return loginSet(config.dependencyBotLogins).has(login.toLowerCase()); +} + +export function isCodeRabbitLogin(login, config = DEFAULTS) { + if (!login) return false; + return loginSet(config.coderabbitLogins).has(login.toLowerCase()); +} + +export function classifyCodeRabbitText(text) { + const body = text || ""; + if (RATE_LIMIT_MARKERS.some((pattern) => pattern.test(body))) { + return "rate_limit"; + } + if (ERROR_MARKERS.some((pattern) => pattern.test(body))) { + return "error"; + } + // CodeRabbit reuses one summary comment. After a completed review it may + // later add `skip review` (incremental off). Treat that as completed. + if (COMPLETED_REVIEW_MARKERS.some((pattern) => pattern.test(body))) { + return "completed"; + } + if (DRAFT_SKIP_MARKERS.some((pattern) => pattern.test(body))) { + return "skip_draft"; + } + return "other"; +} + +export function extractReviewedHeadSha(text) { + const body = text || ""; + const covered = body.match(COVERED_COMMIT); + if (covered?.[1]) return covered[1]; + const between = body.match(BETWEEN_COMMITS); + if (between?.[1] && between[1].length === 40) return between[1]; + const shas = body.match(COMMIT_SHA) || []; + return shas.length > 0 ? shas[shas.length - 1] : null; +} + +export function isExplicitCodeRabbitReviewRequest(comment, config = DEFAULTS) { + if (!comment?.body || isCodeRabbitLogin(comment.user?.login, config)) { + return false; + } + if (hasFallbackMarker(comment.body, null, config)) return false; + return EXPLICIT_REVIEW_REQUEST.test(comment.body); +} + +export function fallbackMarkerHtml(headSha, reason, config = DEFAULTS) { + const sha = headSha || ""; + return [ + ``, + ``, + ``, + ].join("\n"); +} + +export function hasFallbackMarker(body, headSha, config = DEFAULTS) { + const text = body || ""; + if (headSha) { + return text.includes(``); + } + return text.includes(``); +} + +export function classifyCheckOrStatus(item) { + const name = `${item.name || item.context || ""}`; + const description = `${item.description || item.title || item.output?.title || ""}`; + const combined = `${name}\n${description}\n${item.output?.summary || ""}`; + const status = item.status || ""; + const conclusion = item.conclusion || item.state || ""; + + if (/coderabbit|review rate limited/i.test(name) && /queued|in_progress/i.test(status)) { + return "in_progress"; + } + if (classifyCodeRabbitText(combined) === "rate_limit") return "rate_limit"; + if (/coderabbit/i.test(name) && /failure|error/i.test(conclusion)) { + return "error"; + } + if (classifyCodeRabbitText(combined) === "error") return "error"; + return "other"; +} + +function eventTime(item) { + const raw = + item.submitted_at || + item.updated_at || + item.created_at || + item.completed_at || + item.started_at || + 0; + const ms = Date.parse(raw); + return Number.isFinite(ms) ? ms : 0; +} + +export function readyAtMs(pull, timelineEvents = []) { + const readyEvents = timelineEvents + .filter((event) => event.event === "ready_for_review") + .map((event) => Date.parse(event.created_at)) + .filter((ms) => Number.isFinite(ms)); + if (readyEvents.length > 0) return Math.max(...readyEvents); + return Date.parse(pull.created_at) || 0; +} + +function collectSignals({ comments, reviews, checks, statuses }, config) { + const signals = []; + + for (const comment of comments) { + if (!isCodeRabbitLogin(comment.user?.login, config)) continue; + const kind = classifyCodeRabbitText(comment.body); + if (kind === "other") continue; + signals.push({ + kind, + at: eventTime(comment), + headSha: extractReviewedHeadSha(comment.body), + source: "comment", + }); + } + + for (const review of reviews) { + if (!isCodeRabbitLogin(review.user?.login, config)) continue; + const kind = classifyCodeRabbitText(review.body); + if (kind !== "completed" && kind !== "rate_limit" && kind !== "error") { + continue; + } + signals.push({ + kind, + at: eventTime(review), + headSha: review.commit_id || extractReviewedHeadSha(review.body), + source: "review", + }); + } + + for (const item of [...checks, ...statuses]) { + const kind = classifyCheckOrStatus(item); + if (kind === "other") continue; + signals.push({ + kind, + at: eventTime(item), + headSha: item.head_sha || null, + source: "check", + }); + } + + return signals.sort((left, right) => left.at - right.at); +} + +function sameSha(left, right) { + if (!left || !right) return false; + const a = left.toLowerCase(); + const b = right.toLowerCase(); + if (a === b) return true; + return a.startsWith(b) || b.startsWith(a); +} + +export function decidePrFallback(input, config = DEFAULTS) { + const { + pull, + comments = [], + reviews = [], + checks = [], + statuses = [], + timeline = [], + nowMs = Date.now(), + windowMs = (config.windowMinutes || DEFAULTS.windowMinutes) * 60 * 1000, + } = input; + + const number = pull.number; + const headSha = pull.head?.sha || ""; + const title = pull.title || ""; + + const readyAt = readyAtMs(pull, timeline); + + if (pull.draft) { + return skip(number, headSha, title, "draft", "Draft pull requests are skipped.", readyAt); + } + + if (isDependencyBot(pull.user?.login, config)) { + return skip( + number, + headSha, + title, + "dependency_bot", + `Author ${pull.user.login} is a dependency bot.`, + readyAt, + ); + } + + if (comments.some((comment) => hasFallbackMarker(comment.body, headSha, config))) { + return skip( + number, + headSha, + title, + "already_recorded", + "A fallback marker already exists for this head SHA.", + readyAt, + ); + } + + const signals = collectSignals({ comments, reviews, checks, statuses }, config); + const completed = signals.filter((signal) => signal.kind === "completed"); + const completedOnHead = completed.find((signal) => sameSha(signal.headSha, headSha)); + if (completedOnHead) { + return skip( + number, + headSha, + title, + "coderabbit_reviewed_current_head", + "CodeRabbit completed a review that covers the current head.", + readyAt, + ); + } + + const lastCompleted = completed.at(-1) || null; + const explicitRequests = comments + .filter((comment) => isExplicitCodeRabbitReviewRequest(comment, config)) + .map((comment) => ({ at: eventTime(comment), login: comment.user?.login })) + .sort((left, right) => left.at - right.at); + const requestAfterCompleted = explicitRequests.filter( + (request) => !lastCompleted || request.at > lastCompleted.at, + ); + const lastRequest = requestAfterCompleted.at(-1) || null; + + if (lastCompleted && !lastRequest) { + return skip( + number, + headSha, + title, + "coderabbit_reviewed_earlier_head", + "CodeRabbit already reviewed an earlier head and incremental review is off; no explicit @coderabbitai review was requested after that review.", + readyAt, + ); + } + + const latestInProgress = [...signals] + .reverse() + .find((signal) => signal.kind === "in_progress"); + const latestBlocking = [...signals] + .reverse() + .find((signal) => signal.kind === "rate_limit" || signal.kind === "error"); + if ( + latestInProgress && + (!latestBlocking || latestInProgress.at >= latestBlocking.at) + ) { + return skip( + number, + headSha, + title, + "coderabbit_in_progress", + "A CodeRabbit check is still in progress.", + readyAt, + ); + } + const blockingAfterObligation = + latestBlocking && + (!lastCompleted || latestBlocking.at >= lastCompleted.at) && + (!lastRequest || latestBlocking.at >= lastRequest.at); + + if (blockingAfterObligation && latestBlocking.kind === "rate_limit") { + return fallback( + number, + headSha, + title, + "rate_limit", + "CodeRabbit reported a rate limit and has not completed a review of this head.", + readyAt, + ); + } + if (blockingAfterObligation && latestBlocking.kind === "error") { + return fallback( + number, + headSha, + title, + "error", + "CodeRabbit reported a review error and has not completed a review of this head.", + readyAt, + ); + } + + const obligationAt = lastRequest?.at || readyAt; + const elapsedMs = nowMs - obligationAt; + if (obligationAt > 0 && elapsedMs >= windowMs) { + return fallback( + number, + headSha, + title, + "no_review_within_window", + lastRequest + ? `No CodeRabbit review completed within ${Math.round(windowMs / 60000)} minutes of the explicit review request.` + : `No CodeRabbit review completed within ${Math.round(windowMs / 60000)} minutes of the PR becoming ready.`, + readyAt, + ); + } + + return skip( + number, + headSha, + title, + "waiting_for_coderabbit", + `Waiting for CodeRabbit; ${Math.max(0, windowMs - elapsedMs)} ms remain in the window.`, + readyAt, + ); +} + +function skip(number, headSha, title, reason, detail, readyAt = 0) { + return { + action: "skip", + number, + headSha, + title, + reason, + detail, + readyAt, + }; +} + +function fallback(number, headSha, title, reason, detail, readyAt = 0) { + return { + action: "fallback", + number, + headSha, + title, + reason, + detail, + readyAt, + }; +} + +export function selectFallbackBatch(decisions, maxPrsPerRun = DEFAULTS.maxPrsPerRun) { + const eligible = decisions + .filter((decision) => decision.action === "fallback") + .slice() + .sort((left, right) => (left.readyAt || left.number) - (right.readyAt || right.number)); + return { + eligible, + selected: eligible.slice(0, maxPrsPerRun), + throttled: eligible.slice(maxPrsPerRun), + }; +} + +export function buildFallbackComment({ + headSha, + reason, + detail, + windowMinutes = DEFAULTS.windowMinutes, + config = DEFAULTS, + mentionCodex = true, +}) { + const reasonLabel = { + rate_limit: "rate limit", + error: "error / failed to start or complete", + no_review_within_window: `no review within ${windowMinutes} min`, + }[reason] || reason; + + const lines = []; + if (mentionCodex) lines.push(config.codexTrigger, ""); + lines.push( + fallbackMarkerHtml(headSha, reason, config), + "", + "## Fallback review (not a CodeRabbit pass)", + "", + `CodeRabbit was unavailable for head \`${headSha}\`.`, + `Reason: ${reasonLabel}.`, + detail, + "", + mentionCodex + ? "Codex was requested as the independent fallback. This is not a CodeRabbit review and does not count as a CodeRabbit pass." + : "Codex was **not** mentioned because `CODEX_GITHUB_TOKEN` is unset. A PAT from the GitHub user connected to Codex is required; `github-actions[bot]` mentions are not a reliable Codex trigger.", + "", + "See `docs/ops/pr-review-policy.md`.", + ); + return lines.join("\n"); +} + +export function formatMissingCodexTokenNotice(selected) { + const listing = selected + .map((item) => { + const why = item.detail ? `${item.reason}: ${item.detail}` : item.reason; + return `#${item.number} ${why}`; + }) + .join("; "); + return { + annotation: `::warning::CODEX_GITHUB_TOKEN is unset. Would have requested Codex fallback for: ${listing}. Posted nothing.`, + summary: [ + "### Codex fallback skipped: `CODEX_GITHUB_TOKEN` unset", + "", + "Posted nothing. Add a PAT for the GitHub user whose ChatGPT account has this repository connected.", + "", + "Would have requested fallback for:", + ...selected.map((item) => { + const why = item.detail ? `${item.reason}: ${item.detail}` : item.reason; + return `- #${item.number} ${why}`; + }), + ].join("\n"), + }; +} + +function emitMissingCodexTokenNotice(selected, env = process.env) { + const notice = formatMissingCodexTokenNotice(selected); + console.log(notice.annotation); + if (env.GITHUB_STEP_SUMMARY) { + appendFileSync(env.GITHUB_STEP_SUMMARY, `${notice.summary}\n`); + } + return notice; +} + +export function shouldHandleIssueComment({ eventName, commentLogin, commentBody, config = DEFAULTS }) { + if (eventName !== "issue_comment") return true; + if (!isCodeRabbitLogin(commentLogin, config)) return false; + const kind = classifyCodeRabbitText(commentBody); + return kind === "rate_limit" || kind === "error"; +} + +async function githubRequest(config, method, path, body) { + const response = await fetch(`${config.apiBase}${path}`, { + method, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${config.apiToken}`, + "X-GitHub-Api-Version": DEFAULTS.githubApiVersion, + ...(body ? { "Content-Type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`${method} ${path} failed: ${response.status} ${text}`); + } + if (response.status === 204) return null; + return await response.json(); +} + +async function githubPaginate(config, path) { + const items = []; + let next = path; + while (next) { + const response = await fetch(`${config.apiBase}${next}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${config.apiToken}`, + "X-GitHub-Api-Version": DEFAULTS.githubApiVersion, + }, + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`GET ${next} failed: ${response.status} ${text}`); + } + const page = await response.json(); + items.push(...(Array.isArray(page) ? page : page.check_runs || page.statuses || [])); + const link = response.headers.get("link") || ""; + const nextUrl = link.match(/<([^>]+)>;\s*rel="next"/)?.[1]; + if (nextUrl) { + const url = new URL(nextUrl); + next = `${url.pathname}${url.search}`; + } else { + next = null; + } + } + return items; +} + +async function listOpenPulls(config) { + if (config.prNumber) { + const pull = await githubRequest( + config, + "GET", + `/repos/${config.ownerRepo}/pulls/${config.prNumber}`, + ); + return [pull]; + } + return githubPaginate(config, `/repos/${config.ownerRepo}/pulls?state=open&per_page=100`); +} + +async function loadPrEvidence(config, pull) { + const [comments, reviews, timeline, checkPayload, statusPayload] = await Promise.all([ + githubPaginate(config, `/repos/${config.ownerRepo}/issues/${pull.number}/comments?per_page=100`), + githubPaginate(config, `/repos/${config.ownerRepo}/pulls/${pull.number}/reviews?per_page=100`), + githubPaginate(config, `/repos/${config.ownerRepo}/issues/${pull.number}/timeline?per_page=100`), + githubRequest( + config, + "GET", + `/repos/${config.ownerRepo}/commits/${pull.head.sha}/check-runs?per_page=100`, + ).catch(() => ({ check_runs: [] })), + githubRequest( + config, + "GET", + `/repos/${config.ownerRepo}/commits/${pull.head.sha}/status`, + ).catch(() => ({ statuses: [] })), + ]); + return { + comments, + reviews, + timeline, + checks: checkPayload.check_runs || [], + statuses: statusPayload.statuses || [], + }; +} + +async function ensureLabel(config) { + try { + await githubRequest( + config, + "GET", + `/repos/${config.ownerRepo}/labels/${encodeURIComponent(config.label)}`, + ); + } catch { + await githubRequest(config, "POST", `/repos/${config.ownerRepo}/labels`, { + name: config.label, + color: "c2a337", + description: "CodeRabbit was unavailable; Codex was requested as fallback review", + }); + } +} + +async function applyFallback(config, decision) { + const mentionCodex = Boolean(config.commentToken); + if (!mentionCodex) { + throw new Error( + "CODEX_GITHUB_TOKEN is required to mention @codex review. github-actions[bot] comments are not a reliable Codex trigger because Codex maps the comment author to a ChatGPT-connected GitHub account.", + ); + } + + const commentTokenConfig = { ...config, apiToken: config.commentToken }; + await ensureLabel(commentTokenConfig); + await githubRequest( + commentTokenConfig, + "POST", + `/repos/${config.ownerRepo}/issues/${decision.number}/labels`, + { labels: [config.label] }, + ); + await githubRequest( + commentTokenConfig, + "POST", + `/repos/${config.ownerRepo}/issues/${decision.number}/comments`, + { + body: buildFallbackComment({ + headSha: decision.headSha, + reason: decision.reason, + detail: decision.detail, + windowMinutes: config.windowMinutes, + config, + mentionCodex: true, + }), + }, + ); +} + +function printReport(decisions, batch, config) { + const lines = [ + `Fallback window: ${config.windowMinutes} minutes`, + `Throttle: ${config.maxPrsPerRun} PR(s) per run`, + `Dry run: ${config.dryRun}`, + "", + "Decisions:", + ]; + for (const decision of decisions) { + lines.push( + `- #${decision.number} ${decision.action} ${decision.reason} (${decision.headSha.slice(0, 12)}): ${decision.detail}`, + ); + } + if (batch.selected.length > 0) { + lines.push("", config.dryRun ? "Would request fallback for:" : "Requesting fallback for:"); + for (const decision of batch.selected) { + lines.push(`- #${decision.number} ${decision.reason}`); + } + } + if (batch.throttled.length > 0) { + lines.push("", "Throttled until a later run:"); + for (const decision of batch.throttled) { + lines.push(`- #${decision.number} ${decision.reason}`); + } + } + console.log(lines.join("\n")); +} + +export async function runFallback(env = process.env) { + const config = readConfig(env); + if (!config.ownerRepo) throw new Error("GITHUB_REPOSITORY is required"); + if (!config.apiToken) throw new Error("GITHUB_TOKEN is required"); + + if ( + !shouldHandleIssueComment({ + eventName: config.eventName, + commentLogin: config.commentLogin, + commentBody: config.commentBody, + config, + }) + ) { + console.log("Ignoring issue_comment from a non-CodeRabbit failure/rate-limit comment."); + return { decisions: [], selected: [], throttled: [] }; + } + + const pulls = (await listOpenPulls(config)).filter((pull) => !pull.draft); + const decisions = []; + for (const pull of pulls) { + if (isDependencyBot(pull.user?.login, config)) { + decisions.push( + skip( + pull.number, + pull.head?.sha || "", + pull.title || "", + "dependency_bot", + `Author ${pull.user.login} is a dependency bot.`, + ), + ); + continue; + } + const evidence = await loadPrEvidence(config, pull); + decisions.push( + decidePrFallback( + { + pull, + ...evidence, + nowMs: config.nowMs, + windowMs: config.windowMs, + }, + config, + ), + ); + } + + const batch = selectFallbackBatch(decisions, config.maxPrsPerRun); + printReport(decisions, batch, config); + + if (config.dryRun || batch.selected.length === 0) { + return { decisions, ...batch }; + } + + if (!config.commentToken) { + emitMissingCodexTokenNotice(batch.selected, env); + return { decisions, ...batch, skippedMissingToken: true }; + } + + for (const decision of batch.selected) { + await applyFallback(config, decision); + console.log(`Recorded fallback on #${decision.number} for ${decision.headSha}`); + } + return { decisions, ...batch }; +} + +const invokedDirectly = + process.argv[1] && + (process.argv[1].endsWith("pr-review-fallback.mjs") || + process.argv[1].endsWith("pr-review-fallback.js")); + +if (invokedDirectly && process.env.FALLBACK_SKIP_MAIN !== "1") { + runFallback().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fec869d..59519e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,7 @@ jobs: with: node-version: "22" - run: node --test adapters/cursor/hooks/microbridge-event.test.mjs + - run: node --test scripts/pr-review-fallback.test.mjs - run: node -e 'for (const file of ["adapters/cursor/.cursor-plugin/plugin.json", "adapters/cursor/hooks/hooks.json"]) JSON.parse(require("fs").readFileSync(file, "utf8"))' # Keep required-check names stable for the main branch ruleset. diff --git a/.github/workflows/coderabbit-fallback-review.yml b/.github/workflows/coderabbit-fallback-review.yml new file mode 100644 index 0000000..0b979f0 --- /dev/null +++ b/.github/workflows/coderabbit-fallback-review.yml @@ -0,0 +1,78 @@ +# Portable CodeRabbit → Codex fallback reviewer. +# Copy this file and `.github/pr-review-fallback/` into other repos as-is. +# Override the env defaults below if a repo needs a different window or cap. +# +# Fallback only: never requests Codex when CodeRabbit already reviewed, and +# never triggers Cursor Bugbot. Requires repo secret CODEX_GITHUB_TOKEN +# (PAT of the GitHub user connected to Codex) to mention `@codex review`. +# GITHUB_TOKEN / github-actions[bot] mentions are not a reliable Codex trigger. +# Missing CODEX_GITHUB_TOKEN: fail closed and quiet (warning + summary, exit 0). + +name: CodeRabbit fallback review + +on: + schedule: + # Stagger this minute when copying to other repos so they do not fire together. + - cron: "59 * * * *" + issue_comment: + types: [created] + pull_request: + types: [opened, reopened, ready_for_review, synchronize] + workflow_dispatch: + inputs: + dry_run: + description: List which PRs would get a fallback and why, without commenting + type: boolean + default: true + pr_number: + description: Optional pull request number to evaluate + type: string + required: false + +concurrency: + group: coderabbit-fallback-review + cancel-in-progress: false + +permissions: + contents: read + pull-requests: write + issues: write + +env: + FALLBACK_WINDOW_MINUTES: "45" + FALLBACK_MAX_PRS_PER_RUN: "2" + FALLBACK_LABEL: review-fallback-coderabbit-unavailable + FALLBACK_CODEX_TRIGGER: "@codex review" + +jobs: + fallback: + name: Decide Codex fallback + if: github.event_name != 'issue_comment' || github.event.issue.pull_request + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + + - name: Evaluate ready pull requests + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CODEX_GITHUB_TOKEN: ${{ secrets.CODEX_GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + FALLBACK_COMMENT_LOGIN: ${{ github.event.comment.user.login }} + FALLBACK_COMMENT_BODY: ${{ github.event.comment.body }} + # Pin one PR for issue_comment, workflow_dispatch(pr_number), and + # pull_request (the triggering PR). Schedule leaves this empty so the + # hourly sweep can drain the backlog. + FALLBACK_PR_NUMBER: ${{ inputs.pr_number || github.event.issue.number || github.event.pull_request.number }} + # pull_request runs are always dry-run so opening this workflow cannot + # comment on other PRs. schedule and issue_comment may post. + FALLBACK_DRY_RUN: ${{ github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && inputs.dry_run) }} + run: node .github/pr-review-fallback/pr-review-fallback.mjs diff --git a/Makefile b/Makefile index 27c1868..c30daef 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,7 @@ ci: fmt $(MAKE) clippy $(MAKE) test node --test adapters/cursor/hooks/microbridge-event.test.mjs + node --test scripts/pr-review-fallback.test.mjs cd apps/microbridge-ui && npm ci && npm test && npm run build build: diff --git a/docs/ops/pr-review-policy.md b/docs/ops/pr-review-policy.md new file mode 100644 index 0000000..27db2ef --- /dev/null +++ b/docs/ops/pr-review-policy.md @@ -0,0 +1,65 @@ +# PR review and cost controls + +Copied from AllureConnect's PR review policy (approved September 6, 2026, Eastern time) with the October 5, 2026 automatic Codex fallback addendum. This is the current review policy for Microbridge. It supersedes older instructions requiring a particular vendor's zero-finding result; historical review reports remain accurate records of their own checkpoints. It does not amend product implementation plans or change the tag-driven release procedure. + +## Review the completed candidate + +1. Keep work in draft while implementing. Run the applicable local checks, freeze the intended diff, and batch review fixes in one push. Do not push during an active review unless correcting an urgent problem; record that the old review is superseded. +2. CodeRabbit remains the normal reviewer. Automatic review of ready PRs remains enabled. This repository has no `.coderabbit.yaml`, so CodeRabbit defaults apply: [automatic incremental review on each push is on](https://docs.coderabbit.ai/configuration/auto-review), and drafts are skipped unless configured otherwise. Request a final review explicitly after subsequent substantive fixes if the incremental pass did not cover the final candidate. An opening review is sufficient only if it covers the final candidate. +3. Use one coordinated review request at a time for the shared CodeRabbit CLI identity. Do not repeatedly retry during a reported cooldown. Do not buy credits, upgrade plans, or increase spending caps without explicit budget authorization. +4. A green check named skipped, rate limited, or ignored is not evidence that a review ran. Read the actual result, scope and revision. Do not require both hosted and CLI reviews if one completed review covers the whole candidate; missing image/binary coverage must be supplied by an independent visual reviewer. + +CodeRabbit's supported automatic review controls and review accounting are documented in its [configuration reference](https://docs.coderabbit.ai/reference/configuration#auto_review) and [rate-limit guidance](https://docs.coderabbit.ai/management/rate-limits). No review path filters, scan thresholds or test assertions are loosened by this policy. + +## Independent fallback + +When CodeRabbit reports a rate limit, cannot start or complete because of a service/client failure, or cannot cover a changed artifact, record that condition and use an independent reviewer. A reviewer may be a separate Codex review session or agent, a qualified human, or another approved review service. The implementation agent's own reread is not independent review. Existing Codex review uses the account's normal usage allowance; it is not unlimited or an assurance of zero additional usage cost. + +Give the reviewer the frozen base/head and full changed-path inventory, requirements, applicable repository instructions and tests. The reviewer inspects the actual code and relevant surrounding contracts, checks earlier findings and fixes, and directly views changed visual assets. A second provider name alone does not establish completeness. Do not disclose secrets or customer data in prompts, screenshots or retained evidence. + +Record the fallback honestly as an independent review, never as `CodeRabbit: 0`. A provider outage does not waive unresolved findings or permit merging an incomplete review. For auth, billing, credentials, schema, public API or integration changes, the independent review must explicitly evaluate the changed security and compatibility boundaries as well as the relevant negative tests. + +### Automatic Codex fallback + +Jonathan approved this automation on October 5, 2026: request a Codex review automatically only when CodeRabbit cannot review a ready pull request. The workflow is `.github/workflows/coderabbit-fallback-review.yml` and the decision script is `.github/pr-review-fallback/pr-review-fallback.mjs`. Unit tests live at `scripts/pr-review-fallback.test.mjs` so `node --test` (which skips hidden `.github/` directories) actually runs them; CI invokes that file explicitly because this repository does not run a repo-root `node --test`. It is fallback-only and must not run on every pull request. + +Triggers: + +- A CodeRabbit comment that reports a rate limit (`rate limited by coderabbit.ai` / `Review limit reached`) or a failure (`failure by coderabbit.ai` / `Review failed`), including the passing `CodeRabbit` commit status titled `Review rate limited`. +- A scheduled hourly sweep (`59 * * * *`) that drains a backlog a few pull requests at a time. +- `workflow_dispatch` with `dry_run` (default on) to list which pull requests would get a fallback and why, without commenting. + +Window and throttle (defaults, overridable at the top of the workflow): + +- Window: **45 minutes** after the pull request became ready (`ready_for_review`, or `opened` if it was never a draft). The window is not restarted by later pushes. +- This repository has no `.coderabbit.yaml`, so CodeRabbit's default `auto_incremental_review: true` applies (review on each push). The fallback script still treats a completed CodeRabbit review on an earlier head as “CodeRabbit reviewed” unless someone later posted `@coderabbitai review` or `@coderabbitai full review`. That is conservative: it will not request Codex after the first successful CodeRabbit review just because later commits arrived, even if an incremental CodeRabbit pass later rate-limits or fails. First-time ready pull requests that never get a completed CodeRabbit review still fall through to Codex after a rate limit, error, or the 45-minute window. After an explicit re-request, the window starts at the request time. +- Throttle: **2 pull requests per run per repository**. Oldest eligible first. At most one fallback per pull-request head SHA. + +Skip rules: draft pull requests; dependency bots (`dependabot[bot]`, `renovate[bot]`); a fallback marker that already exists for the current head; a completed CodeRabbit review that covers the current head; a CodeRabbit check still in progress. + +How the fallback is recorded: the workflow posts one comment that starts with `@codex review` and includes an HTML marker (`pr-review-fallback:sha:` plus the reason) and applies the label `review-fallback-coderabbit-unavailable`. The comment states that CodeRabbit was unavailable and that Codex is the independent fallback. It is never recorded as a CodeRabbit pass. + +Codex trigger and credential: official Codex docs request a review by commenting `@codex review` on the pull request (ChatGPT Codex GitHub connector). Codex maps the comment author to a ChatGPT-connected GitHub account, so `github-actions[bot]` / `GITHUB_TOKEN` mentions are not a reliable trigger. Set repository secret `CODEX_GITHUB_TOKEN` to a fine-grained PAT (issues and pull requests: write) for the GitHub user whose ChatGPT account has this repository connected and code review enabled at `https://chatgpt.com/codex/settings/code-review`. The ChatGPT Codex Connector GitHub App must have access to the repository. Until that secret is present, scheduled and comment-driven runs that would fire fail closed and quiet: they post nothing, emit a workflow warning plus a job-summary line of the pull requests that would have gotten a fallback, and exit 0 so the hourly sweep does not fail. Genuine script or GitHub API errors still fail the job. + +Bugbot (Cursor) stays manual-only. Do not add Bugbot auto-review configuration and do not have this workflow mention Bugbot. When Jonathan asks for a Bugbot pass on a specific pull request, post a new **top-level** comment on that pull request containing `cursor review` or `bugbot run` (not a thread reply). That is the only automatic-adjacent Bugbot action this policy permits, and it is still a human action. + +## Required evidence and finding disposition + +The review packet, linked from the PR body, must include: + +- Exact base and candidate commit, review timestamp, reviewer identity/tool, and the implementation author so independence is assessable. +- Every changed path and whether it received code review, visual inspection, or another justified form of review. No changed path may silently disappear through tool defaults or filters. +- CodeRabbit failure/rate-limit evidence when fallback was used, without tokens or other secrets. +- Findings with file/line, severity, concrete reasoning or reproduction, and disposition. Each valid actionable defect must be fixed and verified. Disputed findings need an evidence-backed rationale accepted by the independent reviewer and release owner. +- Explicit limitations: checks not run, routes or providers not exercised, and unverified deployment/authentication evidence. Keep wider rollout gates separate from the scoped merge verdict. +- A final review verdict and applicable exact-commit CI results. Preserve initial failures and retry counts; a retry pass is not a first-pass pass. + +The merge verdict requires no unresolved actionable correctness, security, privacy, data-integrity, compatibility, accessibility or usability defects. Optional wording/style suggestions may be recorded as nonblocking with a reason and an owner for any follow-up; they must not force indefinite full-review cycles. Do not relabel a real defect as cosmetic or resolve a thread merely to obtain green status. Every review conversation must have a documented disposition and be resolved before merge. + +After substantive changes, independently review the new diff and its interactions before merging. Earlier review coverage may be retained only when exact reviewed paths are unchanged and that equivalence is recorded; review all integration/conflict changes. Metadata-only PR-body updates do not invalidate source review. Record the new merge SHA and tree equivalence after squash; any source drift needs renewed applicable review and checks. + +## Release gates stay in place + +This policy changes review scheduling and provider availability only. Required `rust (ubuntu-latest)`, `rust (macos-latest)`, `ui`, and other applicable checks still apply to the exact candidate. No branch-protection check is removed or bypassed. Never use a reviewer approval to conceal a failing required check. + +Before a release tag, verify the exact merged source and required checks. Homebrew formula bumps, signed DMG / tarball assets, and finalize-release smokes remain separate from the scoped merge verdict. diff --git a/scripts/pr-review-fallback.test.mjs b/scripts/pr-review-fallback.test.mjs new file mode 100644 index 0000000..8ae622c --- /dev/null +++ b/scripts/pr-review-fallback.test.mjs @@ -0,0 +1,482 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULTS, + buildFallbackComment, + classifyCheckOrStatus, + classifyCodeRabbitText, + decidePrFallback, + extractReviewedHeadSha, + formatMissingCodexTokenNotice, + hasFallbackMarker, + isDependencyBot, + isExplicitCodeRabbitReviewRequest, + readyAtMs, + readConfig, + selectFallbackBatch, + shouldHandleIssueComment, +} from "../.github/pr-review-fallback/pr-review-fallback.mjs"; + +const RATE_LIMIT_COMMENT = ` + + +> [!WARNING] +> ## Review limit reached +> +> Enable **usage-based reviews** in Billing to review now. +> **Next included review available in 58 seconds.** +`; + +const FAILURE_COMMENT = ` + + +> [!CAUTION] +> ## Review failed +`; + +const DRAFT_SKIP_COMMENT = ` + +> [!IMPORTANT] +> ## Draft PR not reviewed +`; + +const COMPLETED_REVIEW_BODY = `**Actionable comments posted: 1** + + +`; + +const WALKTHROUGH_COMMENT = ` + +**Change:** Bug fix + +`; + +function pull({ + number = 579, + draft = false, + login = "DevVig", + sha = "67ec5b01a1b9e249b2b1fd0acaf19097cdbf137c", + created = "2026-10-05T15:31:06Z", + title = "Example", +} = {}) { + return { + number, + draft, + title, + created_at: created, + user: { login }, + head: { sha }, + }; +} + +function comment({ + login = "coderabbitai[bot]", + body = RATE_LIMIT_COMMENT, + created = "2026-10-05T15:32:39Z", +} = {}) { + return { user: { login }, body, created_at: created }; +} + +test("classifies this repo's CodeRabbit rate-limit HTML", () => { + assert.equal(classifyCodeRabbitText(RATE_LIMIT_COMMENT), "rate_limit"); +}); + +test("classifies this repo's CodeRabbit failure HTML", () => { + assert.equal(classifyCodeRabbitText(FAILURE_COMMENT), "error"); +}); + +test("classifies draft-skip comments as skip_draft, not completed", () => { + assert.equal(classifyCodeRabbitText(DRAFT_SKIP_COMMENT), "skip_draft"); +}); + +test("keeps a completed review when CodeRabbit later adds skip-review to the same comment", () => { + const body = `${DRAFT_SKIP_COMMENT}\n${WALKTHROUGH_COMMENT}`; + assert.equal(classifyCodeRabbitText(body), "completed"); +}); + +test("classifies a completed CodeRabbit review body", () => { + assert.equal(classifyCodeRabbitText(COMPLETED_REVIEW_BODY), "completed"); + assert.equal(classifyCodeRabbitText(WALKTHROUGH_COMMENT), "completed"); +}); + +test("extracts coveredCommitId from a walkthrough comment", () => { + assert.equal( + extractReviewedHeadSha(WALKTHROUGH_COMMENT), + "083903d896e5ef55a97e5ddcf1ed2bf98530e7d2", + ); +}); + +test("waits when a later CodeRabbit check is still running", () => { + const decision = decidePrFallback({ + pull: pull(), + comments: [comment({ created: "2026-10-05T15:32:39Z" })], + checks: [ + { + name: "CodeRabbit", + status: "in_progress", + started_at: "2026-10-05T16:00:00Z", + }, + ], + nowMs: Date.parse("2026-10-05T16:05:00Z"), + windowMs: 45 * 60 * 1000, + }); + assert.equal(decision.action, "skip"); + assert.equal(decision.reason, "coderabbit_in_progress"); +}); + +test("classifies CodeRabbit commit status used on this repo", () => { + assert.equal( + classifyCheckOrStatus({ + context: "CodeRabbit", + state: "success", + description: "Review rate limited", + }), + "rate_limit", + ); + assert.equal( + classifyCheckOrStatus({ + name: "CodeRabbit", + status: "in_progress", + }), + "in_progress", + ); + assert.equal( + classifyCheckOrStatus({ + name: "CodeRabbit", + conclusion: "failure", + output: { title: "Review crashed" }, + }), + "error", + ); +}); + +test("detects dependabot and renovate authors", () => { + assert.equal(isDependencyBot("dependabot[bot]"), true); + assert.equal(isDependencyBot("renovate[bot]"), true); + assert.equal(isDependencyBot("DevVig"), false); +}); + +test("skips drafts and dependency bots", () => { + assert.equal( + decidePrFallback({ pull: pull({ draft: true }) }).reason, + "draft", + ); + assert.equal( + decidePrFallback({ + pull: pull({ login: "dependabot[bot]" }), + }).reason, + "dependency_bot", + ); +}); + +test("fires on a ready PR whose only CodeRabbit result is a rate limit", () => { + const decision = decidePrFallback({ + pull: pull(), + comments: [comment()], + statuses: [ + { + context: "CodeRabbit", + state: "success", + description: "Review rate limited", + created_at: "2026-10-05T15:32:40Z", + }, + ], + nowMs: Date.parse("2026-10-06T00:00:00Z"), + windowMs: 45 * 60 * 1000, + }); + assert.equal(decision.action, "fallback"); + assert.equal(decision.reason, "rate_limit"); +}); + +test("fires on a CodeRabbit failure comment", () => { + const decision = decidePrFallback({ + pull: pull({ number: 302 }), + comments: [comment({ body: FAILURE_COMMENT })], + nowMs: Date.parse("2026-10-06T00:00:00Z"), + windowMs: 45 * 60 * 1000, + }); + assert.equal(decision.action, "fallback"); + assert.equal(decision.reason, "error"); +}); + +test("does nothing when CodeRabbit reviewed the current head", () => { + const sha = "083903d896e5ef55a97e5ddcf1ed2bf98530e7d2"; + const decision = decidePrFallback({ + pull: pull({ sha }), + reviews: [ + { + user: { login: "coderabbitai[bot]" }, + body: COMPLETED_REVIEW_BODY, + commit_id: sha, + submitted_at: "2026-10-03T19:57:03Z", + }, + ], + nowMs: Date.parse("2026-10-06T00:00:00Z"), + windowMs: 45 * 60 * 1000, + }); + assert.equal(decision.action, "skip"); + assert.equal(decision.reason, "coderabbit_reviewed_current_head"); +}); + +test("does not wait after CodeRabbit appends skip-review to a completed walkthrough", () => { + const laterHead = "4b786bebaa7a8b9bd95cc11c34d607f2d3150ee7"; + const decision = decidePrFallback({ + pull: pull({ number: 582, sha: laterHead }), + comments: [ + comment({ + body: `${DRAFT_SKIP_COMMENT}\n${WALKTHROUGH_COMMENT}`, + created: "2026-10-06T00:28:32Z", + }), + ], + nowMs: Date.parse("2026-10-06T00:50:00Z"), + windowMs: 45 * 60 * 1000, + }); + assert.equal(decision.action, "skip"); + assert.equal(decision.reason, "coderabbit_reviewed_earlier_head"); +}); + +test("does not misfire after later pushes when incremental review is off", () => { + const reviewed = "083903d896e5ef55a97e5ddcf1ed2bf98530e7d2"; + const laterHead = "7ccd6c5ed5d8a15acc0e419d74143989fedeec84"; + const decision = decidePrFallback({ + pull: pull({ number: 551, sha: laterHead }), + comments: [comment({ body: WALKTHROUGH_COMMENT, created: "2026-09-30T05:58:04Z" })], + reviews: [ + { + user: { login: "coderabbitai[bot]" }, + body: COMPLETED_REVIEW_BODY, + commit_id: reviewed, + submitted_at: "2026-10-03T19:57:03Z", + }, + ], + nowMs: Date.parse("2026-10-06T00:00:00Z"), + windowMs: 45 * 60 * 1000, + }); + assert.equal(decision.action, "skip"); + assert.equal(decision.reason, "coderabbit_reviewed_earlier_head"); +}); + +test("window is relative to ready_for_review, not the latest push", () => { + const created = "2026-10-05T15:31:06Z"; + const ready = "2026-10-05T22:42:53Z"; + const now = Date.parse("2026-10-05T23:10:00Z"); + const decision = decidePrFallback({ + pull: pull({ created }), + timeline: [{ event: "ready_for_review", created_at: ready }], + nowMs: now, + windowMs: 45 * 60 * 1000, + }); + assert.equal(decision.action, "skip"); + assert.equal(decision.reason, "waiting_for_coderabbit"); +}); + +test("fires no_review_within_window after the PR has been ready long enough", () => { + const ready = "2026-10-05T22:42:53Z"; + const decision = decidePrFallback({ + pull: pull({ created: "2026-10-05T15:31:06Z" }), + timeline: [{ event: "ready_for_review", created_at: ready }], + nowMs: Date.parse("2026-10-05T23:40:00Z"), + windowMs: 45 * 60 * 1000, + }); + assert.equal(decision.action, "fallback"); + assert.equal(decision.reason, "no_review_within_window"); +}); + +test("an explicit @coderabbitai review after an earlier review reopens the window", () => { + const reviewed = "083903d896e5ef55a97e5ddcf1ed2bf98530e7d2"; + const laterHead = "7ccd6c5ed5d8a15acc0e419d74143989fedeec84"; + const decision = decidePrFallback({ + pull: pull({ sha: laterHead }), + reviews: [ + { + user: { login: "coderabbitai[bot]" }, + body: COMPLETED_REVIEW_BODY, + commit_id: reviewed, + submitted_at: "2026-10-03T19:57:03Z", + }, + ], + comments: [ + comment({ + login: "DevVig", + body: "@coderabbitai review", + created: "2026-10-04T03:00:00Z", + }), + ], + nowMs: Date.parse("2026-10-04T04:00:00Z"), + windowMs: 45 * 60 * 1000, + }); + assert.equal(decision.action, "fallback"); + assert.equal(decision.reason, "no_review_within_window"); +}); + +test("a rate-limit after an explicit re-review request is a fallback", () => { + const reviewed = "083903d896e5ef55a97e5ddcf1ed2bf98530e7d2"; + const laterHead = "7ccd6c5ed5d8a15acc0e419d74143989fedeec84"; + const decision = decidePrFallback({ + pull: pull({ sha: laterHead }), + reviews: [ + { + user: { login: "coderabbitai[bot]" }, + body: COMPLETED_REVIEW_BODY, + commit_id: reviewed, + submitted_at: "2026-10-03T19:57:03Z", + }, + ], + comments: [ + comment({ + login: "DevVig", + body: "@coderabbitai full review", + created: "2026-10-04T03:00:00Z", + }), + comment({ + body: RATE_LIMIT_COMMENT, + created: "2026-10-04T03:01:00Z", + }), + ], + nowMs: Date.parse("2026-10-04T03:02:00Z"), + windowMs: 45 * 60 * 1000, + }); + assert.equal(decision.action, "fallback"); + assert.equal(decision.reason, "rate_limit"); +}); + +test("fires at most once per head SHA when a marker comment exists", () => { + const sha = "67ec5b01a1b9e249b2b1fd0acaf19097cdbf137c"; + const decision = decidePrFallback({ + pull: pull({ sha }), + comments: [ + comment(), + comment({ + login: "github-actions[bot]", + body: buildFallbackComment({ + headSha: sha, + reason: "rate_limit", + detail: "already requested", + }), + }), + ], + nowMs: Date.parse("2026-10-06T00:00:00Z"), + }); + assert.equal(decision.action, "skip"); + assert.equal(decision.reason, "already_recorded"); +}); + +test("selectFallbackBatch respects the per-run cap and keeps oldest-first order", () => { + const decisions = [ + { action: "skip", number: 1, reason: "draft" }, + { action: "fallback", number: 579, reason: "rate_limit", readyAt: 3 }, + { action: "fallback", number: 503, reason: "rate_limit", readyAt: 1 }, + { action: "fallback", number: 600, reason: "no_review_within_window", readyAt: 2 }, + ]; + const batch = selectFallbackBatch(decisions, 2); + assert.deepEqual( + batch.selected.map((item) => item.number), + [503, 600], + ); + assert.deepEqual( + batch.throttled.map((item) => item.number), + [579], + ); +}); + +test("issue_comment handler only continues for CodeRabbit rate-limit or error", () => { + assert.equal( + shouldHandleIssueComment({ + eventName: "issue_comment", + commentLogin: "vercel[bot]", + commentBody: "preview", + }), + false, + ); + assert.equal( + shouldHandleIssueComment({ + eventName: "issue_comment", + commentLogin: "coderabbitai[bot]", + commentBody: RATE_LIMIT_COMMENT, + }), + true, + ); + assert.equal( + shouldHandleIssueComment({ + eventName: "schedule", + commentLogin: "", + commentBody: "", + }), + true, + ); +}); + +test("readyAtMs uses the latest ready_for_review event", () => { + const ms = readyAtMs(pull({ created: "2026-10-03T19:00:00Z" }), [ + { event: "ready_for_review", created_at: "2026-10-03T19:25:02Z" }, + { event: "convert_to_draft", created_at: "2026-10-03T19:30:56Z" }, + { event: "ready_for_review", created_at: "2026-10-03T19:50:59Z" }, + ]); + assert.equal(ms, Date.parse("2026-10-03T19:50:59Z")); +}); + +test("fallback comment records reason, SHA, and Codex mention without claiming a CodeRabbit pass", () => { + const sha = "67ec5b01a1b9e249b2b1fd0acaf19097cdbf137c"; + const body = buildFallbackComment({ + headSha: sha, + reason: "rate_limit", + detail: "CodeRabbit reported a rate limit.", + windowMinutes: 45, + }); + assert.equal(hasFallbackMarker(body, sha), true); + assert.match(body, /@codex review/); + assert.match(body, /not a CodeRabbit pass/); + assert.match(body, /rate limit/); + assert.equal(isExplicitCodeRabbitReviewRequest({ user: { login: "bot" }, body }), false); +}); + +test("readConfig honors workflow defaults from env", () => { + const config = readConfig({ + FALLBACK_WINDOW_MINUTES: "30", + FALLBACK_MAX_PRS_PER_RUN: "1", + FALLBACK_DRY_RUN: "true", + GITHUB_REPOSITORY: "acme/widgets", + }); + assert.equal(config.windowMinutes, 30); + assert.equal(config.maxPrsPerRun, 1); + assert.equal(config.dryRun, true); + assert.equal(config.label, DEFAULTS.label); + assert.equal(config.prNumber, null); +}); + +test("readConfig scopes evaluation to FALLBACK_PR_NUMBER when set", () => { + const scoped = readConfig({ + FALLBACK_PR_NUMBER: "582", + GITHUB_REPOSITORY: "acme/widgets", + }); + assert.equal(scoped.prNumber, 582); + const sweep = readConfig({ + FALLBACK_PR_NUMBER: "", + GITHUB_REPOSITORY: "acme/widgets", + }); + assert.equal(sweep.prNumber, null); +}); + +test("missing CODEX_GITHUB_TOKEN is a quiet warning, not a job failure", () => { + const notice = formatMissingCodexTokenNotice([ + { + number: 503, + reason: "rate_limit", + detail: "CodeRabbit reported a rate limit and has not completed a review of this head.", + }, + { + number: 579, + reason: "rate_limit", + detail: "CodeRabbit reported a rate limit and has not completed a review of this head.", + }, + ]); + assert.match(notice.annotation, /^::warning::CODEX_GITHUB_TOKEN is unset/); + assert.match(notice.annotation, /Posted nothing/); + assert.match(notice.annotation, /#503 rate_limit/); + assert.match(notice.annotation, /#579 rate_limit/); + assert.match(notice.summary, /Would have requested fallback for:/); + assert.match(notice.summary, /#503 rate_limit:/); + assert.match(notice.summary, /#579 rate_limit:/); + assert.doesNotMatch(notice.annotation, /exit 1|throw|fatal/i); +});