============================================================ FILE: gemini-pr-reviewer/index.js (< deploy > monorepo-main) ============================================================ 9c9,11 < cliArgs: ['-p', '-', '-o', 'text', '-m', 'gemini-3.1-pro-preview'], --- > // --approval-mode plan = read-only (no tool execution); diff supplied via > // stdin. See ask-ai.js for the sandbox rationale. > cliArgs: ['-p', '-', '-o', 'text', '-m', 'gemini-2.5-pro', '--approval-mode', 'plan'], (diff rc=1) ============================================================ FILE: codex-pr-reviewer/index.js (< deploy > monorepo-main) ============================================================ 8,12c8,11 < // Shim reads stdin and execs `codex exec ` because codex CLI takes the < // prompt as a positional arg, not from stdin. The shared askAI() helper pipes < // the prompt to stdin, so we go via the shim. < binaryPath: '/home/claude/repos/codex-pr-reviewer/codex-stdin-shim.sh', < cliArgs: [], --- > binaryPath: `${process.env.HOME}/.local/bin/codex`, > // --skip-git-repo-check: empty sandbox has no .git; -s read-only: no writes. > // Diff is supplied via stdin. See ask-ai.js for the sandbox rationale. > cliArgs: ['exec', '--skip-git-repo-check', '-m', 'gpt-5.4', '-s', 'read-only'], (diff rc=1) ============================================================ FILE: claude-pr-reviewer/index.js (< deploy > monorepo-main) ============================================================ 13,16d12 < // Disable all tools — the review prompt is self-contained; tool calls just < // cause "Reached max turns (1)" exit 1 on large diffs (claude tries to < // look up extra context, can't, dies). --max-turns kept as a hard cap. < '--tools', '', (diff rc=1) ============================================================ FILE: pr-review-shared/lib/ask-ai.js (< deploy > monorepo-main) ============================================================ 2c2 < import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'; --- > import { mkdtempSync, rmSync } from 'node:fs'; 17,21d16 < * Default timeout was 180s, which caused ~12% of claude reviews to fail with < * exit code 143 (SIGTERM) on large diffs. Bumped to 600s (10 min) — gemini < * typically finishes in <60s, claude in 60-240s on real PRs. killSignal is < * SIGINT so the LLM CLI can flush partial output before dying. < * 26c21 < * @param {number} [options.timeout=600000] - Subprocess timeout in ms --- > * @param {number} [options.timeout=180000] - Subprocess timeout in ms 31c26 < export function askAI(binary, args, prompt, { timeout = 600_000, cwd } = {}) { --- > export function askAI(binary, args, prompt, { timeout = 180_000, cwd } = {}) { 50d44 < killSignal: 'SIGINT', 69,75d62 < let killedByTimeout = false; < < // Node's spawn timeout fires the kill signal and then closes. We capture < // that path explicitly so the rejection says "timed out" instead of the < // generic "exited with code 143" — the latter sent us on a wild-goose < // chase on 2026-05-24. < const timer = setTimeout(() => { killedByTimeout = true; }, timeout); 80,81c67 < child.on('close', (code, signal) => { < clearTimeout(timer); --- > child.on('close', (code) => { 85,86d70 < } else if (killedByTimeout || signal === 'SIGINT' || signal === 'SIGTERM') { < reject(new Error(`${binary} timed out after ${timeout}ms (signal=${signal || 'unknown'}, code=${code}). Bump the timeout or shrink the prompt. Partial stdout (${stdout.length}b): ${stdout.slice(0, 200)}`)); 88,90c72 < // Many CLIs (claude included) print user-facing errors to stdout, not < // stderr. Surface BOTH so the next failure is debuggable on sight. < reject(new Error(`${binary} exited with code ${code} (signal=${signal || 'none'}). stderr(${stderr.length}b)='${stderr.slice(0, 400)}' stdout(${stdout.length}b)='${stdout.slice(0, 400)}'`)); --- > reject(new Error(`${binary} exited with code ${code}: ${stderr.slice(0, 500)}`)); 94,101c76,79 < child.on('error', (err) => { clearTimeout(timer); cleanup(); reject(err); }); < < // Dump the prompt to /tmp for post-mortem debugging. Per-binary file so < // claude + gemini don't stomp each other. Best-effort — never block on it. < try { < const tag = (binary.split('/').pop() || 'cli').replace(/[^a-z0-9_-]/gi, '_'); < writeFileSync(`/tmp/last-prompt-${tag}.txt`, prompt); < } catch (_) { /* swallow */ } --- > child.on('error', (err) => { > cleanup(); > reject(err); > }); (diff rc=1) ============================================================ FILE: pr-review-shared/lib/check-resolution.js (< deploy > monorepo-main) ============================================================ 5c5 < export async function checkResolution(octokit, { owner, repo, pull_number }) { --- > export async function checkResolution(octokit, { owner, repo, pull_number, botName = null }) { 31,33c31,44 < const resolved = threads.filter(t => t.isResolved).length; < const outdated = threads.filter(t => t.isOutdated && !t.isResolved).length; < const unresolvedThreads = threads.filter(t => !t.isResolved && !t.isOutdated); --- > const resolved = threads.filter((t) => t.isResolved).length; > const outdated = threads.filter((t) => t.isOutdated && !t.isResolved).length; > // When botName is provided, only count this bot's own unresolved threads. > // This prevents Bot A from blocking its own APPROVE because Bot B has unresolved threads. > const unresolvedThreads = threads.filter((t) => { > if (t.isResolved || t.isOutdated) { > return false; > } > if (botName) { > const author = t.comments.nodes[0]?.author?.login || ""; > return author.toLowerCase().includes(botName.toLowerCase()); > } > return true; > }); 35c46 < const unresolvedDetails = unresolvedThreads.map(t => { --- > const unresolvedDetails = unresolvedThreads.map((t) => { 38,40c49,51 < author: c.author?.login || 'unknown', < body: c.body || '', < path: c.path || '', --- > author: c.author?.login || "unknown", > body: c.body || "", > path: c.path || "", (diff rc=1) ============================================================ FILE: pr-review-shared/lib/post-review.js (< deploy > monorepo-main) ============================================================ 4,5c4,25 < export async function postStartedReview(octokit, { owner, repo, pull_number, botName, isReReview = false }) { < const verb = isReReview ? 're-reviewing' : 'reviewing'; --- > export async function postStartedReview( > octokit, > { owner, repo, pull_number, botName, isReReview = false }, > ) { > // Dedup: skip if this bot already has a pending "is reviewing" comment > try { > const { data: reviews } = await octokit.rest.pulls.listReviews({ owner, repo, pull_number }); > const hasPending = reviews.some( > (r) => > (r.user?.login?.includes(botName.toLowerCase()) && > r.state === "COMMENTED" && > r.body?.includes("is reviewing")) || > r.body?.includes("is re-reviewing"), > ); > if (hasPending) { > return null; > } // already posted, skip > } catch { > /* proceed anyway if check fails */ > } > > const verb = isReReview ? "re-reviewing" : "reviewing"; 11c31 < event: 'COMMENT', --- > event: "COMMENT", 21,22c41,45 < export async function postFullReview(octokit, { owner, repo, pull_number, botName, summary, comments, verdict }) { < const event = verdict === 'approve' ? 'APPROVE' : 'REQUEST_CHANGES'; --- > export async function postFullReview( > octokit, > { owner, repo, pull_number, botName, summary, comments, verdict }, > ) { > const event = verdict === "approve" ? "APPROVE" : "REQUEST_CHANGES"; 45,46c68,74 < export async function updateStartedReviewNoFiles(octokit, { owner, repo, pull_number, reviewId, botName }) { < if (!reviewId) return; --- > export async function updateStartedReviewNoFiles( > octokit, > { owner, repo, pull_number, reviewId, botName }, > ) { > if (!reviewId) { > return; > } 64,65c92,98 < export async function updateStartedReviewOnError(octokit, { owner, repo, pull_number, reviewId, botName, error }) { < if (!reviewId) return; --- > export async function updateStartedReviewOnError( > octokit, > { owner, repo, pull_number, reviewId, botName, error }, > ) { > if (!reviewId) { > return; > } 72c105 < body: `⚠️ **${botName}**: Review failed — ${error.message || 'unknown error'}`, --- > body: `⚠️ **${botName}**: Review failed — ${error.message || "unknown error"}`, (diff rc=1) ============================================================ FILE: pr-review-shared/lib/review-handler.js (< deploy > monorepo-main) ============================================================ 1,11c1,16 < import { filterFiles } from './filter-files.js'; < import { buildDiff } from './build-diff.js'; < import { buildPromptV2, fetchCodebaseContext, extractPlanLink } from './build-prompt-v2.js'; < import { validateComments } from './validate-hunks.js'; < import { parseReviewResponse } from './parse-response.js'; < import { askAI } from './ask-ai.js'; < import { postStartedReview, postFullReview, updateStartedReviewOnError, updateStartedReviewNoFiles } from './post-review.js'; < import { listAllFiles } from './list-files.js'; < import { dismissOwnStaleReview } from './dismiss-stale-reviews.js'; < import { checkResolution } from './check-resolution.js'; < import { buildReReviewPrompt, fetchPriorReview } from './re-review.js'; --- > import { askAI } from "./ask-ai.js"; > import { buildDiff } from "./build-diff.js"; > import { buildPromptV2, fetchCodebaseContext, extractPlanLink } from "./build-prompt-v2.js"; > import { checkResolution } from "./check-resolution.js"; > import { dismissOwnStaleReview } from "./dismiss-stale-reviews.js"; > import { filterFiles } from "./filter-files.js"; > import { listAllFiles } from "./list-files.js"; > import { parseReviewResponse } from "./parse-response.js"; > import { > postStartedReview, > postFullReview, > updateStartedReviewOnError, > updateStartedReviewNoFiles, > } from "./post-review.js"; > import { buildReReviewPrompt, fetchPriorReview } from "./re-review.js"; > import { validateComments } from "./validate-hunks.js"; 31c36,38 < if (pr.state !== 'open') return; --- > if (pr.state !== "open") { > return; > } 33,34c40,44 < const { owner: { login: owner }, name: repo } = context.payload.repository; < const isSynchronize = context.payload.action === 'synchronize'; --- > const { > owner: { login: owner }, > name: repo, > } = context.payload.repository; > const isSynchronize = context.payload.action === "synchronize"; 37,40c47,50 < // Repo-context guard (#804): the repo under review is ALWAYS the one that < // fired the webhook (context.payload.repository), at the PR head SHA. < // Assert it agrees with the PR's base repo before doing anything, so we < // never review a diff against the wrong tree. Mismatch => log + abort. --- > // Repo-context guard: the repo under review is ALWAYS the one that fired > // the webhook (context.payload.repository), at the PR head SHA. Assert it > // agrees with the PR's base repo before doing anything, so we never review > // a diff against the wrong tree. Mismatch => log + abort (don't review). 51c61 < `Repo under review: ${webhookRepo} @ ${prHeadSha ?? 'unknown-sha'} (head: ${pr.head?.repo?.full_name ?? '?'})`, --- > `Repo under review: ${webhookRepo} @ ${prHeadSha ?? "unknown-sha"} (head: ${pr.head?.repo?.full_name ?? "?"})`, 60,61c70,76 < priorReviewData = await fetchPriorReview(context.octokit, { owner, repo, pull_number: pr.number, botName }); < if (priorReviewData?.state === 'CHANGES_REQUESTED') { --- > priorReviewData = await fetchPriorReview(context.octokit, { > owner, > repo, > pull_number: pr.number, > botName, > }); > if (priorReviewData?.state === "CHANGES_REQUESTED") { 65c80,85 < await dismissOwnStaleReview(context.octokit, { owner, repo, pull_number: pr.number, botName }); --- > await dismissOwnStaleReview(context.octokit, { > owner, > repo, > pull_number: pr.number, > botName, > }); 68c88,94 < startedReviewId = await postStartedReview(context.octokit, { owner, repo, pull_number: pr.number, botName, isReReview }); --- > startedReviewId = await postStartedReview(context.octokit, { > owner, > repo, > pull_number: pr.number, > botName, > isReReview, > }); 70c96,100 < const prFiles = await listAllFiles(context.octokit, { owner, repo, pull_number: pr.number }); --- > const prFiles = await listAllFiles(context.octokit, { > owner, > repo, > pull_number: pr.number, > }); 74c104,110 < await updateStartedReviewNoFiles(context.octokit, { owner, repo, pull_number: pr.number, reviewId: startedReviewId, botName }); --- > await updateStartedReviewNoFiles(context.octokit, { > owner, > repo, > pull_number: pr.number, > reviewId: startedReviewId, > botName, > }); 83,84c119,127 < prompt = buildReReviewPrompt({ prNumber: pr.number, prTitle: pr.title, diffText: diff, priorReview: priorReviewData }); < app.log.info(`Re-reviewing PR #${pr.number} against ${priorReviewData.comments.length} prior comments`); --- > prompt = buildReReviewPrompt({ > prNumber: pr.number, > prTitle: pr.title, > diffText: diff, > priorReview: priorReviewData, > }); > app.log.info( > `Re-reviewing PR #${pr.number} against ${priorReviewData.comments.length} prior comments`, > ); 91c134,141 < prompt = buildPromptV2({ prNumber: pr.number, prTitle: pr.title, prBody: pr.body, diffText: diff, codebaseContext, planLink }); --- > prompt = buildPromptV2({ > prNumber: pr.number, > prTitle: pr.title, > prBody: pr.body, > diffText: diff, > codebaseContext, > planLink, > }); 94,96c144,146 < // Empty-diff guard (#804): the prompt must contain the diff we fetched < // from THIS PR. If the diff is empty the CLI would be tempted to inspect < // its own working dir, so refuse rather than review the wrong tree. --- > // Final guard before spending a model call: the prompt must contain the > // diff we fetched from THIS PR. If the diff is empty the CLI would be > // tempted to inspect its own working dir, so refuse rather than review. 101c151,157 < await updateStartedReviewNoFiles(context.octokit, { owner, repo, pull_number: pr.number, reviewId: startedReviewId, botName }); --- > await updateStartedReviewNoFiles(context.octokit, { > owner, > repo, > pull_number: pr.number, > reviewId: startedReviewId, > botName, > }); 105c161,163 < app.log.info(`Invoking ${botName} CLI for PR #${pr.number} in ${webhookRepo} (${prompt.length} chars)...`); --- > app.log.info( > `Invoking ${botName} CLI for PR #${pr.number} in ${webhookRepo} (${prompt.length} chars)...`, > ); 110,113c168,176 < // Check thread resolution before posting < const resolution = await checkResolution(context.octokit, { owner, repo, pull_number: pr.number }); < let verdict = result.verdict || 'request_changes'; < let summaryAppendix = ''; --- > // Check only THIS bot's unresolved threads (not other bots') > const resolution = await checkResolution(context.octokit, { > owner, > repo, > pull_number: pr.number, > botName, > }); > let verdict = result.verdict || "request_changes"; > let summaryAppendix = ""; 115,116c178,179 < if (resolution.unresolved > 0 && verdict === 'approve') { < verdict = 'request_changes'; --- > if (resolution.unresolved > 0 && verdict === "approve") { > verdict = "request_changes"; 133c196,199 < owner, repo, pull_number: pr.number, botName, --- > owner, > repo, > pull_number: pr.number, > botName, 139c205,229 < app.log.info(`Review submitted for PR #${pr.number} (${validComments.length} inline comments, verdict: ${verdict})`); --- > app.log.info( > `Review submitted for PR #${pr.number} (${validComments.length} inline comments, verdict: ${verdict})`, > ); > > // Notify the PR author's agent via tmux (best-effort) > try { > const author = pr.user?.login ?? ""; > const agentPane = { > gbharg: "mbp:codex.1", > "gautam-claude": "mbp:claude.1", > "gautam-gemini": "mbp:gemini.1", > }[author]; > if (agentPane) { > const msg = `[${botName}] Review posted on PR #${pr.number}: ${verdict} (${validComments.length} comments)`; > const { execFile } = await import("node:child_process"); > execFile( > "tmux", > ["send-keys", "-t", agentPane, msg, "Enter"], > { timeout: 3000 }, > () => {}, > ); > } > } catch { > /* best-effort notification */ > } 142c232,239 < await updateStartedReviewOnError(context.octokit, { owner, repo, pull_number: pr.number, reviewId: startedReviewId, botName, error }); --- > await updateStartedReviewOnError(context.octokit, { > owner, > repo, > pull_number: pr.number, > reviewId: startedReviewId, > botName, > error, > }); (diff rc=1) === codex-stdin-shim.sh (deploy-only) full content === #!/bin/bash # codex-stdin-shim.sh — read prompt from stdin, exec codex with it as a # positional arg. The shared review-handler's askAI() pipes prompts via stdin. set -euo pipefail prompt=$(cat) exec /home/claude/.local/bin/codex exec \ --skip-git-repo-check \ --dangerously-bypass-approvals-and-sandbox \ "$prompt" --- end shim --- === gemini api/ dir (deploy-only) listing === /home/claude/repos/gemini-pr-reviewer/api: total 12 drwxr-xr-x 3 claude claude 4096 Mar 19 19:05 . drwxr-xr-x 4 claude claude 4096 May 24 12:45 .. drwxr-xr-x 2 claude claude 4096 Mar 19 19:05 github /home/claude/repos/gemini-pr-reviewer/api/github: total 12 drwxr-xr-x 2 claude claude 4096 Mar 19 19:05 . drwxr-xr-x 3 claude claude 4096 Mar 19 19:05 .. -rw-r--r-- 1 claude claude 209 Mar 19 19:05 webhooks.js === start.sh files (deploy-only) — check for secrets/env coupling === --- gemini-pr-reviewer/start.sh --- #!/bin/bash # start.sh — Launch Gemini PR Reviewer (Probot with built-in Smee proxy) # Guard: kill any existing instance to prevent duplicate webhook responses existing=$(pgrep -f "gemini-pr-reviewer.*probot" 2>/dev/null | grep -v "$$" | head -1) if [ -n "$existing" ]; then echo "[gemini-pr-reviewer] Killing stale instance (PID $existing)" kill "$existing" 2>/dev/null sleep 1 fi cd /home/claude/repos/gemini-pr-reviewer exec /usr/bin/npx probot run ./index.js --- codex-pr-reviewer/start.sh --- #!/bin/bash # start.sh — Launch Codex PR Reviewer (Probot + Smee). existing=$(pgrep -f "codex-pr-reviewer.*probot" 2>/dev/null | grep -v "$$" | head -1) if [ -n "$existing" ]; then echo "[codex-pr-reviewer] Killing stale instance (PID $existing)" kill "$existing" 2>/dev/null sleep 1 fi cd /home/claude/repos/codex-pr-reviewer exec /usr/bin/npx probot run ./index.js --- claude-pr-reviewer/start.sh --- #!/bin/bash # start.sh — Launch Claude PR Reviewer (Probot with built-in Smee proxy) # Guard: kill any existing instance to prevent duplicate webhook responses existing=$(pgrep -f "claude-pr-reviewer.*probot" 2>/dev/null | grep -v "$$" | head -1) if [ -n "$existing" ]; then echo "[claude-pr-reviewer] Killing stale instance (PID $existing)" kill "$existing" 2>/dev/null sleep 1 fi cd /home/claude/repos/claude-pr-reviewer exec /usr/bin/npx probot run ./index.js === claude-pr-reviewer/package.json (deploy-only) === { "name": "claude-pr-reviewer", "version": "1.0.0", "description": "Automated PR reviews via Claude CLI (Probot GitHub App)", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "start": "PORT=3002 probot run ./index.js" }, "keywords": [], "author": "", "license": "ISC", "type": "module", "dependencies": { "dotenv": "^17.3.1", "probot": "^14.2.4" }, "devDependencies": { "smee-client": "^5.0.0" } } === .gitignore in gemini (deploy-only) === .vercel .env*.local === DONE_02 ===