Files
rocksdb/.github/workflows/ai-review-analysis.yml
T
Maciej Szeszko 46ce1a03a9 Cap Claude thinking budget (#14731)
Summary:
### Motivation
Claude's auto review workflow classifies the PR as complex and sets `MAX_THINKING_TOKENS`=**32000** in `.github/workflows/ai-review-analysis.yml:534`. The Claude action then sends a request where `thinking.budget_tokens` is **32000**, but the request `max_tokens` is not greater than that, so Anthropic rejects it before the review starts ([example](https://github.com/facebook/rocksdb/actions/runs/25691112276/job/75427435133)).

### Fix
Reduce the "complex" thinking budget from **32000** to **24000** tokens and clamps manual overrides to the same ceiling, preventing the thinking budget from exceeding or equalling the action's effective `max_tokens`. 24k is still generous — enough for deep reasoning on complex PRs while guaranteeing the model can emit the formatted review.

Pull Request resolved: https://github.com/facebook/rocksdb/pull/14731

Reviewed By: xingbowang

Differential Revision: D104749426

Pulled By: mszeszko-meta

fbshipit-source-id: bf94d0f50e7c2c9bc9e4d4cdffd61087d739018a
2026-05-11 17:45:43 -07:00

1502 lines
60 KiB
YAML

# Shared analysis workflow for AI PR reviews.
#
# This workflow is invoked by provider-specific wrappers (Claude/Codex) so the
# event triggers and workflow names stay user-friendly while the implementation
# lives in one place.
#
# Security note for Codex:
# Codex runs in non-interactive CI, so the Codex CLI steps below pass
# `--dangerously-bypass-approvals-and-sandbox`. That disables Codex's
# approval prompts and sandbox protections, so treat those steps as
# arbitrary command execution on the runner. This file therefore keeps Codex
# in the read-only analysis workflow, restricts the pull_request_target path
# to same-repo PRs, and leaves write-scoped PR commenting in a separate
# workflow. Re-evaluate that trust boundary before expanding Codex auto
# review to broader trigger sources.
name: AI Review Analysis
on:
workflow_call:
inputs:
provider:
description: Provider key, for example "claude" or "codex"
required: true
type: string
display_name:
description: Provider display name used in job names
required: true
type: string
review_command:
description: Slash command for manual reviews
required: true
type: string
query_command:
description: Slash command for manual queries
required: true
type: string
result_artifact_name:
description: Artifact name used to pass comment payload to the poster
required: true
type: string
comment_file:
description: Markdown file written by the analysis workflow
required: true
type: string
default_model:
description: Default model used for auto reviews and manual reviews
required: true
type: string
selected_model:
description: Optional workflow_dispatch model override
required: false
default: ''
type: string
classifier_model:
description: Optional classifier model override for Claude
required: false
default: ''
type: string
recovery_model:
description: Optional recovery model override for Claude
required: false
default: ''
type: string
thinking_budget:
description: Optional manual override used by workflow_dispatch
required: false
default: ''
type: string
dispatch_pr_number:
description: Optional workflow_dispatch PR number
required: false
default: ''
type: string
authorized_users_json:
description: JSON array of users allowed to run manual review commands
required: false
default: >-
["pdillinger","jaykorean","hx235","dannyhchen","joshkang97","zaidoon1","omkarhgawde","xingbowang","anand1976","virajthakur","nmk70","archang19","mszeszko-meta","yoshinorim","cbi42"]
type: string
jobs:
auto-review:
name: Auto ${{ inputs.display_name }} Review
runs-on: ubuntu-latest
concurrency:
# Serialize the early pull_request_target path and the workflow_run
# fallback per provider+commit so both cannot review the same SHA in
# parallel.
group: >-
ai-review-auto-${{ inputs.provider }}-${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.event.workflow_run.head_sha }}
cancel-in-progress: false
permissions:
contents: read
# Read workflow/artifact state so the workflow_run fallback can tell
# whether an early run already produced the review artifact.
actions: read
# Poll check conclusions for the early-review threshold gate.
checks: read
# Load PR metadata and resolve head/base refs without broader access.
pull-requests: read
if: >-
(github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request') ||
(github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Get PR info and apply auto-review gate
id: pr_info
uses: actions/github-script@v7
env:
RESULT_ARTIFACT_NAME: ${{ inputs.result_artifact_name }}
with:
script: |
const isEarlyTrigger = context.eventName === 'pull_request_target';
const FAIL_CONCLUSIONS = new Set([
'failure', 'cancelled', 'timed_out', 'action_required'
]);
const AI_CHECK_TOKENS = ['claude', 'codex'];
const MIN_CHECKS = 10;
const THRESHOLD = 0.5;
const POLL_INTERVAL_MS = 90 * 1000;
const MAX_WAIT_MS = 20 * 60 * 1000;
const FALLBACK_WAIT_MS = 15 * 60 * 1000;
async function sleep(ms) {
await new Promise(resolve => setTimeout(resolve, ms));
}
async function listChecks(ref) {
return await github.paginate(
github.rest.checks.listForRef,
{
owner: context.repo.owner,
repo: context.repo.repo,
ref,
per_page: 100,
}
);
}
function filterAiReviewChecks(checks) {
return checks.filter(check => {
const name = (check.name || '').toLowerCase();
return !AI_CHECK_TOKENS.some(token => name.includes(token));
});
}
async function findOpenPrByHeadSha(headSha) {
for (let attempt = 0; attempt < 5; attempt++) {
if (attempt > 0) {
core.info(
`PR not found for SHA ${headSha}, retrying in 10s ` +
`(attempt ${attempt + 1}/5)...`
);
await sleep(10 * 1000);
}
const {data: pulls} = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'updated',
direction: 'desc',
per_page: 100,
});
const match = pulls.find(p => p.head.sha === headSha);
if (match) {
return match;
}
}
return null;
}
async function findMatchingEarlyRun(headSha) {
const workflowRun = context.payload.workflow_run;
const runs = await github.paginate(
github.rest.actions.listWorkflowRunsForRepo,
{
owner: context.repo.owner,
repo: context.repo.repo,
event: 'pull_request_target',
head_sha: headSha,
per_page: 100,
}
);
return runs
.filter(run =>
run.name === workflowRun.name &&
run.path === workflowRun.path)
.sort((a, b) =>
Date.parse(b.created_at) - Date.parse(a.created_at))[0] ||
null;
}
async function waitForRunCompletion(run) {
let current = run;
const deadline = Date.now() + FALLBACK_WAIT_MS;
while (current.status !== 'completed' && Date.now() < deadline) {
core.info(
`Waiting for early review run ${current.id} ` +
`(${current.status}) before fallback review proceeds...`
);
await sleep(30 * 1000);
const {data} = await github.rest.actions.getWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: current.id,
});
current = data;
}
return current;
}
async function hasResultArtifact(runId) {
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts,
{
owner: context.repo.owner,
repo: context.repo.repo,
run_id: runId,
per_page: 100,
}
);
return artifacts.some(
artifact => artifact.name === process.env.RESULT_ARTIFACT_NAME);
}
let prNumber = null;
let sourceHeadSha = '';
let pr = null;
if (isEarlyTrigger) {
prNumber = context.payload.pull_request.number;
sourceHeadSha = context.payload.pull_request.head.sha;
const start = Date.now();
while (true) {
const {data} = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
pr = data;
if (pr.head.sha !== sourceHeadSha) {
core.info(
`PR #${prNumber} advanced from ${sourceHeadSha} to ` +
`${pr.head.sha}. Skipping stale early review run.`
);
core.setOutput('skip', 'true');
core.setOutput('skip_reason', `stale_sha:${sourceHeadSha}`);
return;
}
const checks = filterAiReviewChecks(
await listChecks(sourceHeadSha));
const total = checks.length;
const succeeded = checks.filter(
check => check.status === 'completed' &&
check.conclusion === 'success').length;
const failed = checks.filter(
check => check.status === 'completed' &&
FAIL_CONCLUSIONS.has(check.conclusion)).length;
const ratio = total > 0 ? succeeded / total : 0;
core.info(
`[early-gate] sha=${sourceHeadSha.substring(0, 7)} ` +
`total=${total} succeeded=${succeeded} failed=${failed} ` +
`ratio=${ratio.toFixed(2)}`
);
if (total >= MIN_CHECKS && failed === 0 && ratio >= THRESHOLD) {
core.info(
`[early-gate] passed: ${succeeded}/${total} succeeded. ` +
'Proceeding with early review.'
);
core.setOutput('auto_mode', 'early');
break;
}
if (failed > 0) {
core.info(
`[early-gate] ${failed} checks have failed. ` +
'Skipping early review and letting the workflow_run ' +
'fallback decide whether to review this commit.'
);
core.setOutput('skip', 'true');
core.setOutput('skip_reason', `gate:failed_checks(${failed})`);
return;
}
if (Date.now() - start >= MAX_WAIT_MS) {
core.info(
'[early-gate] Timed out after 20 minutes waiting for ' +
'the early-review threshold. Skipping and relying on ' +
'the workflow_run fallback.'
);
core.setOutput('skip', 'true');
core.setOutput('skip_reason', 'gate:timeout');
return;
}
await sleep(POLL_INTERVAL_MS);
}
} else {
sourceHeadSha = context.payload.workflow_run.head_sha;
const earlyRun = await findMatchingEarlyRun(sourceHeadSha);
if (earlyRun) {
const observed = await waitForRunCompletion(earlyRun);
if (observed.status === 'completed' &&
observed.conclusion === 'success' &&
await hasResultArtifact(observed.id)) {
core.info(
`Early review run ${observed.id} already produced ` +
`artifact ${process.env.RESULT_ARTIFACT_NAME}. ` +
'Skipping fallback review.'
);
core.setOutput('skip', 'true');
core.setOutput(
'skip_reason',
`early_run_completed:${observed.id}`
);
return;
}
}
const prsFromEvent = context.payload.workflow_run.pull_requests || [];
if (prsFromEvent.length > 0) {
prNumber = prsFromEvent[0].number;
} else {
const match = await findOpenPrByHeadSha(sourceHeadSha);
if (match) {
prNumber = match.number;
}
}
if (!prNumber) {
core.warning(
`Could not find PR for SHA ${sourceHeadSha}. ` +
'Skipping stale fallback review run.'
);
core.setOutput('skip', 'true');
core.setOutput('skip_reason', `stale_sha:${sourceHeadSha}`);
return;
}
const {data} = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
pr = data;
if (pr.head.sha !== sourceHeadSha) {
core.info(
`PR #${prNumber} advanced from ${sourceHeadSha} to ` +
`${pr.head.sha}. Skipping stale fallback review run.`
);
core.setOutput('skip', 'true');
core.setOutput('skip_reason', `stale_sha:${sourceHeadSha}`);
return;
}
core.setOutput('auto_mode', 'late');
}
core.setOutput('skip', 'false');
core.setOutput('pr_number', pr.number.toString());
core.setOutput('head_repo', pr.head.repo.clone_url);
core.setOutput('head_ref', pr.head.ref);
core.setOutput('head_sha', pr.head.sha);
core.setOutput('base_sha', pr.base.sha);
core.setOutput('base_ref', pr.base.ref);
core.setOutput('pr_title', pr.title);
core.setOutput('pr_body', pr.body || '');
core.setOutput('pr_author', pr.user.login);
- name: Skip stale run
if: steps.pr_info.outputs.skip == 'true'
run: |
echo "Skipping auto-review run: ${{ steps.pr_info.outputs.skip_reason }}"
- name: Check for existing review
if: steps.pr_info.outputs.skip != 'true'
id: check_existing
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ steps.pr_info.outputs.pr_number }}
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
PROVIDER: ${{ inputs.provider }}
with:
script: |
const comments = await github.paginate(
github.rest.issues.listComments,
{
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(process.env.PR_NUMBER, 10),
per_page: 100,
}
);
const shortSha = process.env.HEAD_SHA.substring(0, 7);
const marker =
`<!-- ${process.env.PROVIDER}-review-auto-${shortSha} -->`;
const existing = comments.find(
comment => typeof comment.body === 'string' &&
comment.body.includes(marker));
core.setOutput('skip', existing ? 'true' : 'false');
- name: Checkout base repository
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
uses: actions/checkout@v4
with:
ref: ${{ steps.pr_info.outputs.base_ref }}
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
if: >-
steps.pr_info.outputs.skip != 'true' &&
steps.check_existing.outputs.skip != 'true' &&
inputs.provider == 'codex'
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install Codex CLI
if: >-
steps.pr_info.outputs.skip != 'true' &&
steps.check_existing.outputs.skip != 'true' &&
inputs.provider == 'codex'
run: |
npm install -g @openai/codex
codex --version
- name: Generate diff and prompt
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
env:
HEAD_REPO: ${{ steps.pr_info.outputs.head_repo }}
HEAD_REF: ${{ steps.pr_info.outputs.head_ref }}
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
BASE_SHA: ${{ steps.pr_info.outputs.base_sha }}
PR_TITLE: ${{ steps.pr_info.outputs.pr_title }}
PR_BODY: ${{ steps.pr_info.outputs.pr_body }}
PR_AUTHOR: ${{ steps.pr_info.outputs.pr_author }}
run: |
git remote add fork "${HEAD_REPO}" || true
git fetch fork "${HEAD_REF}" --depth=100
git diff "${BASE_SHA}...${HEAD_SHA}" > /tmp/pr.diff
DIFF_STATS=$(git diff --stat "${BASE_SHA}...${HEAD_SHA}" | tail -1)
DIFF_SIZE=$(wc -c < /tmp/pr.diff)
IS_TRUNCATED=false
if [ "${DIFF_SIZE}" -gt 100000 ]; then
head -c 100000 /tmp/pr.diff > /tmp/pr_truncated.diff
mv /tmp/pr_truncated.diff /tmp/pr.diff
IS_TRUNCATED=true
fi
cat claude_md/ci_review_prompt.md > /tmp/prompt.txt
printf '\n## Pull Request Information\n' >> /tmp/prompt.txt
printf '%s\n' "- **Title:** ${PR_TITLE}" >> /tmp/prompt.txt
printf '%s\n' "- **Author:** ${PR_AUTHOR}" >> /tmp/prompt.txt
printf '%s\n' "- **Changes:** ${DIFF_STATS}" >> /tmp/prompt.txt
if [ "${IS_TRUNCATED}" = "true" ]; then
echo "- **Note:** Diff truncated due to size." >> /tmp/prompt.txt
fi
printf '\n## PR Description\n' >> /tmp/prompt.txt
printf '%s\n' "${PR_BODY}" >> /tmp/prompt.txt
printf '\n---\n\n## Diff to Review\n\n' >> /tmp/prompt.txt
cat /tmp/pr.diff >> /tmp/prompt.txt
- name: Build complexity-classifier prompt
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
run: |
cp claude_md/ci_complexity_eval_prompt.md /tmp/complexity_prompt.txt
printf '\n---\n\n## Diff Summary\n\n' >> /tmp/complexity_prompt.txt
git diff --stat "${{ steps.pr_info.outputs.base_sha }}...${{ steps.pr_info.outputs.head_sha }}" >> /tmp/complexity_prompt.txt
printf '\n## Changed Files\n\n' >> /tmp/complexity_prompt.txt
git diff --name-only "${{ steps.pr_info.outputs.base_sha }}...${{ steps.pr_info.outputs.head_sha }}" >> /tmp/complexity_prompt.txt
printf '\n## Diff (truncated to 30KB)\n\n' >> /tmp/complexity_prompt.txt
head -c 30000 /tmp/pr.diff >> /tmp/complexity_prompt.txt
- name: Classify PR complexity (Claude)
if: >-
steps.pr_info.outputs.skip != 'true' &&
steps.check_existing.outputs.skip != 'true' &&
inputs.provider == 'claude'
id: classify_claude
uses: anthropics/claude-code-base-action@beta
with:
prompt_file: /tmp/complexity_prompt.txt
model: ${{ inputs.classifier_model }}
max_turns: "10"
timeout_minutes: "10"
allowed_tools: "View,GlobTool,GrepTool"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Pick thinking budget (Claude)
if: >-
steps.pr_info.outputs.skip != 'true' &&
steps.check_existing.outputs.skip != 'true' &&
inputs.provider == 'claude'
id: budget_claude
env:
EXECUTION_FILE: ${{ steps.classify_claude.outputs.execution_file }}
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let label = 'complex';
try {
const log = JSON.parse(
fs.readFileSync(process.env.EXECUTION_FILE, 'utf8'));
const result = log.find(message => message.type === 'result');
const text = result && result.result ?
result.result.trim().toLowerCase() :
'';
if (/\bsimple\b/.test(text) && !/\bcomplex\b/.test(text)) {
label = 'simple';
}
} catch (error) {
core.warning(
`Could not parse classifier output: ${error.message}`);
}
// Keep Claude's thinking budget below the action's effective
// max_tokens so the API has room for the final review text.
const budget = label === 'simple' ? '16000' : '24000';
core.info(
`Complexity classified as: ${label} ` +
`-> MAX_THINKING_TOKENS=${budget}`
);
core.setOutput('label', label);
core.setOutput('tokens', budget);
- name: Classify PR complexity (Codex)
if: >-
steps.pr_info.outputs.skip != 'true' &&
steps.check_existing.outputs.skip != 'true' &&
inputs.provider == 'codex'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_HOME: /tmp/codex-home
CODEX_MODEL: ${{ inputs.default_model }}
run: |
if [ -z "${OPENAI_API_KEY}" ]; then
echo "OPENAI_API_KEY not configured, skipping Codex review"
printf 'complex\n' > codex-complexity-output.txt
exit 0
fi
mkdir -p "${CODEX_HOME}"
set +e
codex exec \
-m "${CODEX_MODEL}" \
-c model_reasoning_effort="medium" \
--dangerously-bypass-approvals-and-sandbox \
--output-last-message codex-complexity-output.txt \
- < /tmp/complexity_prompt.txt > codex-complexity.log 2>&1
exit_code=$?
set -e
if [ "${exit_code}" -ne 0 ]; then
echo "Codex complexity classification failed with exit code ${exit_code}"
echo "Defaulting to complex review budget"
if [ -s codex-complexity.log ]; then
echo "Last 100 lines of codex-complexity.log:"
tail -100 codex-complexity.log
fi
printf 'complex\n' > codex-complexity-output.txt
fi
- name: Pick thinking budget (Codex)
if: >-
steps.pr_info.outputs.skip != 'true' &&
steps.check_existing.outputs.skip != 'true' &&
inputs.provider == 'codex'
id: budget_codex
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let label = 'complex';
try {
const text = fs.readFileSync(
'codex-complexity-output.txt',
'utf8'
).trim().toLowerCase();
if (/\bsimple\b/.test(text) && !/\bcomplex\b/.test(text)) {
label = 'simple';
}
} catch (error) {
core.warning(
`Could not parse classifier output: ${error.message}`);
}
const effort = label === 'simple' ? 'high' : 'xhigh';
core.info(
`Complexity classified as: ${label} ` +
`-> model_reasoning_effort=${effort}`
);
core.setOutput('label', label);
core.setOutput('effort', effort);
- name: Run Claude
if: >-
steps.pr_info.outputs.skip != 'true' &&
steps.check_existing.outputs.skip != 'true' &&
inputs.provider == 'claude'
id: claude
env:
MAX_THINKING_TOKENS: ${{ steps.budget_claude.outputs.tokens }}
CLAUDE_COMPLEXITY: ${{ steps.budget_claude.outputs.label }}
uses: anthropics/claude-code-base-action@beta
with:
prompt_file: /tmp/prompt.txt
model: ${{ inputs.default_model }}
max_turns: "300"
timeout_minutes: "60"
allowed_tools: "View,GlobTool,GrepTool,Write,Task"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Recover partial review on turn limit (Claude)
if: >-
steps.pr_info.outputs.skip != 'true' &&
steps.check_existing.outputs.skip != 'true' &&
inputs.provider == 'claude'
id: recover_claude
uses: actions/github-script@v7
env:
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
with:
script: |
const fs = require('fs');
const log = JSON.parse(
fs.readFileSync(process.env.EXECUTION_FILE, 'utf8'));
const result = log.find(message => message.type === 'result');
const hitLimit = result && result.subtype === 'error_max_turns';
const hasFindings = fs.existsSync('review-findings.md');
core.setOutput('hit_limit', hitLimit ? 'true' : 'false');
core.setOutput('has_findings', hasFindings ? 'true' : 'false');
core.setOutput(
'needs_recovery',
hitLimit && hasFindings ? 'true' : 'false'
);
- name: Format partial findings (Claude)
if: >-
steps.pr_info.outputs.skip != 'true' &&
steps.check_existing.outputs.skip != 'true' &&
inputs.provider == 'claude' &&
steps.recover_claude.outputs.needs_recovery == 'true'
id: format_recovery_claude
uses: anthropics/claude-code-base-action@beta
with:
prompt_file: claude_md/ci_recovery_prompt.md
model: ${{ inputs.recovery_model }}
max_turns: "10"
allowed_tools: "View,GlobTool,GrepTool"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Build review comment (Claude)
if: >-
steps.pr_info.outputs.skip != 'true' &&
steps.check_existing.outputs.skip != 'true' &&
inputs.provider == 'claude'
env:
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
RECOVERY_FILE: ${{ steps.format_recovery_claude.outputs.execution_file }}
CONCLUSION: ${{ steps.claude.outputs.conclusion }}
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
AUTO_MODE: ${{ steps.pr_info.outputs.auto_mode }}
NEEDS_RECOVERY: ${{ steps.recover_claude.outputs.needs_recovery }}
COMMENT_FILE: ${{ inputs.comment_file }}
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const parse = require('./.github/scripts/parse-claude-review.js');
let execFile = process.env.EXECUTION_FILE;
if (process.env.NEEDS_RECOVERY === 'true') {
const recoveryFile = process.env.RECOVERY_FILE;
if (recoveryFile && fs.existsSync(recoveryFile)) {
execFile = recoveryFile;
}
}
const body = parse({
executionFile: execFile,
conclusion: process.env.CONCLUSION,
meta: {
trigger: 'auto',
autoMode: process.env.AUTO_MODE,
headSha: process.env.HEAD_SHA,
reviewer: '',
isQuery: false,
isPartial: process.env.NEEDS_RECOVERY === 'true',
}
});
fs.writeFileSync(process.env.COMMENT_FILE, body);
- name: Run Codex
if: >-
steps.pr_info.outputs.skip != 'true' &&
steps.check_existing.outputs.skip != 'true' &&
inputs.provider == 'codex'
id: codex
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_HOME: /tmp/codex-home
CODEX_MODEL: ${{ inputs.default_model }}
CODEX_REASONING_EFFORT: ${{ steps.budget_codex.outputs.effort }}
BASE_SHA: ${{ steps.pr_info.outputs.base_sha }}
PR_TITLE: ${{ steps.pr_info.outputs.pr_title }}
run: |
if [ -z "${OPENAI_API_KEY}" ]; then
echo "OPENAI_API_KEY not configured, skipping Codex review"
exit 0
fi
mkdir -p "${CODEX_HOME}"
set +e
codex exec review \
--base "${BASE_SHA}" \
--title "${PR_TITLE}" \
-m "${CODEX_MODEL}" \
-c model_reasoning_effort="${CODEX_REASONING_EFFORT}" \
--dangerously-bypass-approvals-and-sandbox \
--output-last-message codex-review-output.md \
- < /tmp/prompt.txt > codex-review-execution.log 2>&1
exit_code=$?
set -e
echo "exit_code=${exit_code}" >> "${GITHUB_OUTPUT}"
echo "${exit_code}" > codex-review-exit-code.txt
- name: Recover partial review (Codex)
if: >-
steps.pr_info.outputs.skip != 'true' &&
steps.check_existing.outputs.skip != 'true' &&
inputs.provider == 'codex' &&
steps.codex.outputs.exit_code != '0' &&
hashFiles('review-findings.md') != ''
id: recover_codex
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_HOME: /tmp/codex-home
CODEX_MODEL: ${{ inputs.default_model }}
CODEX_REASONING_EFFORT: ${{ steps.budget_codex.outputs.effort }}
run: |
set +e
codex exec \
-m "${CODEX_MODEL}" \
-c model_reasoning_effort="${CODEX_REASONING_EFFORT}" \
--dangerously-bypass-approvals-and-sandbox \
--output-last-message codex-review-recovery.md \
- < claude_md/ci_recovery_prompt.md > codex-review-recovery.log 2>&1
exit_code=$?
set -e
echo "exit_code=${exit_code}" >> "${GITHUB_OUTPUT}"
- name: Build review comment (Codex)
if: >-
steps.pr_info.outputs.skip != 'true' &&
steps.check_existing.outputs.skip != 'true' &&
inputs.provider == 'codex'
env:
RESPONSE_FILE: codex-review-output.md
RECOVERY_FILE: codex-review-recovery.md
FINDINGS_FILE: review-findings.md
LOG_FILE: codex-review-execution.log
EXIT_CODE: ${{ steps.codex.outputs.exit_code }}
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
AUTO_MODE: ${{ steps.pr_info.outputs.auto_mode }}
USED_RECOVERY: ${{ steps.recover_codex.outputs.exit_code == '0' && 'true' || 'false' }}
COMMENT_FILE: ${{ inputs.comment_file }}
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const parse = require('./.github/scripts/parse-codex-review.js');
const body = parse({
responseFile: process.env.RESPONSE_FILE,
recoveryFile: process.env.RECOVERY_FILE,
findingsFile: process.env.FINDINGS_FILE,
logFile: process.env.LOG_FILE,
exitCode: process.env.EXIT_CODE,
meta: {
trigger: 'auto',
autoMode: process.env.AUTO_MODE,
headSha: process.env.HEAD_SHA,
reviewer: '',
isQuery: false,
isPartial: process.env.USED_RECOVERY === 'true' ||
(!fs.existsSync(process.env.RESPONSE_FILE) &&
fs.existsSync(process.env.FINDINGS_FILE)),
}
});
fs.writeFileSync(process.env.COMMENT_FILE, body);
- name: Save PR number
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
run: |
echo "${{ steps.pr_info.outputs.pr_number }}" > pr_number.txt
- name: Save trigger metadata
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
run: |
echo "auto" > trigger_type.txt
echo "${{ steps.pr_info.outputs.head_sha }}" > head_sha.txt
- name: Upload review artifact
if: always() && steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.result_artifact_name }}
path: |
${{ inputs.comment_file }}
pr_number.txt
trigger_type.txt
head_sha.txt
if-no-files-found: ignore
retention-days: 1
- name: Upload execution log (Claude)
if: >-
always() &&
steps.pr_info.outputs.skip != 'true' &&
steps.check_existing.outputs.skip != 'true' &&
inputs.provider == 'claude'
uses: actions/upload-artifact@v4
with:
name: claude-review-execution-log
path: ${{ steps.claude.outputs.execution_file }}
retention-days: 7
if-no-files-found: warn
- name: Upload recovery execution log (Claude)
if: >-
always() &&
steps.pr_info.outputs.skip != 'true' &&
inputs.provider == 'claude' &&
steps.recover_claude.outputs.needs_recovery == 'true'
uses: actions/upload-artifact@v4
with:
name: claude-review-recovery-log
path: ${{ steps.format_recovery_claude.outputs.execution_file }}
retention-days: 7
if-no-files-found: ignore
- name: Upload review logs (Codex)
if: >-
always() &&
steps.pr_info.outputs.skip != 'true' &&
steps.check_existing.outputs.skip != 'true' &&
inputs.provider == 'codex'
uses: actions/upload-artifact@v4
with:
name: codex-review-logs
path: |
codex-review-execution.log
codex-complexity.log
codex-complexity-output.txt
codex-review-recovery.log
codex-review-output.md
codex-review-recovery.md
review-findings.md
codex-review-exit-code.txt
retention-days: 7
if-no-files-found: ignore
manual-review:
name: Manual ${{ inputs.display_name }} Review
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
if: >-
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
(contains(github.event.comment.body, inputs.review_command) ||
contains(github.event.comment.body, inputs.query_command))
)
steps:
- name: Detect command type and authorization
id: request
env:
AUTHORIZED_USERS_JSON: ${{ inputs.authorized_users_json }}
REVIEW_COMMAND: ${{ inputs.review_command }}
QUERY_COMMAND: ${{ inputs.query_command }}
uses: actions/github-script@v7
with:
script: |
const authorizedUsers = JSON.parse(
process.env.AUTHORIZED_USERS_JSON);
if (context.eventName === 'workflow_dispatch') {
core.setOutput('skip', 'false');
core.setOutput('type', 'review');
core.setOutput('query', '');
core.setOutput('reviewer', context.actor);
core.setOutput('comment_id', '');
return;
}
const reviewer = context.payload.comment.user.login;
if (!authorizedUsers.includes(reviewer)) {
core.info(`Ignoring manual review request from @${reviewer}.`);
core.setOutput('skip', 'true');
core.setOutput('skip_reason', `unauthorized:${reviewer}`);
return;
}
const body = context.payload.comment.body;
if (body.includes(process.env.REVIEW_COMMAND)) {
const match = body.match(
new RegExp(
`${process.env.REVIEW_COMMAND.replace('/', '\\/')}\\s+([\\s\\S]*)`
)
);
core.setOutput('skip', 'false');
core.setOutput('type', 'review');
core.setOutput('query', match ? match[1].trim() : '');
} else if (body.includes(process.env.QUERY_COMMAND)) {
const match = body.match(
new RegExp(
`${process.env.QUERY_COMMAND.replace('/', '\\/')}\\s+([\\s\\S]*)`
)
);
const query = match ? match[1].trim() : '';
if (!query) {
core.setFailed(
`No query provided. Usage: ${process.env.QUERY_COMMAND} ` +
'<your question>'
);
return;
}
core.setOutput('skip', 'false');
core.setOutput('type', 'query');
core.setOutput('query', query);
} else {
core.setOutput('skip', 'true');
core.setOutput('skip_reason', 'unrelated_comment');
return;
}
core.setOutput('reviewer', reviewer);
core.setOutput('comment_id', String(context.payload.comment.id || ''));
- name: Skip request
if: steps.request.outputs.skip == 'true'
run: |
echo "Skipping manual request: ${{ steps.request.outputs.skip_reason }}"
- name: Get PR details
if: steps.request.outputs.skip != 'true'
id: pr_info
env:
INPUT_PR_NUMBER: ${{ inputs.dispatch_pr_number }}
uses: actions/github-script@v7
with:
script: |
const prNumber = context.eventName === 'workflow_dispatch' ?
parseInt(process.env.INPUT_PR_NUMBER, 10) :
context.issue.number;
const {data: pr} = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
core.setOutput('pr_number', prNumber.toString());
core.setOutput('head_repo', pr.head.repo.clone_url);
core.setOutput('head_ref', pr.head.ref);
core.setOutput('head_sha', pr.head.sha);
core.setOutput('base_sha', pr.base.sha);
core.setOutput('base_ref', pr.base.ref);
core.setOutput('pr_title', pr.title);
core.setOutput('pr_body', pr.body || '');
core.setOutput('pr_author', pr.user.login);
- name: Checkout base repository
if: steps.request.outputs.skip != 'true'
uses: actions/checkout@v4
with:
ref: ${{ steps.pr_info.outputs.base_ref }}
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
if: steps.request.outputs.skip != 'true' && inputs.provider == 'codex'
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install Codex CLI
if: steps.request.outputs.skip != 'true' && inputs.provider == 'codex'
run: |
npm install -g @openai/codex
codex --version
- name: Generate diff and prompt
if: steps.request.outputs.skip != 'true'
env:
HEAD_REPO: ${{ steps.pr_info.outputs.head_repo }}
HEAD_REF: ${{ steps.pr_info.outputs.head_ref }}
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
BASE_SHA: ${{ steps.pr_info.outputs.base_sha }}
PR_TITLE: ${{ steps.pr_info.outputs.pr_title }}
PR_BODY: ${{ steps.pr_info.outputs.pr_body }}
PR_AUTHOR: ${{ steps.pr_info.outputs.pr_author }}
COMMAND_TYPE: ${{ steps.request.outputs.type }}
ADDITIONAL_CONTEXT: ${{ steps.request.outputs.query }}
USER_QUERY: ${{ steps.request.outputs.query }}
run: |
git remote add fork "${HEAD_REPO}" || true
git fetch fork "${HEAD_REF}" --depth=100
git diff "${BASE_SHA}...${HEAD_SHA}" > /tmp/pr.diff
DIFF_STATS=$(git diff --stat "${BASE_SHA}...${HEAD_SHA}" | tail -1)
DIFF_SIZE=$(wc -c < /tmp/pr.diff)
IS_TRUNCATED=false
if [ "${DIFF_SIZE}" -gt 100000 ]; then
head -c 100000 /tmp/pr.diff > /tmp/pr_truncated.diff
mv /tmp/pr_truncated.diff /tmp/pr.diff
IS_TRUNCATED=true
fi
if [ "${COMMAND_TYPE}" = "query" ]; then
cat claude_md/ci_query_prompt.md > /tmp/prompt.txt
printf '\n---\n\n## Pull Request Context\n' >> /tmp/prompt.txt
printf '%s\n' "- **Title:** ${PR_TITLE}" >> /tmp/prompt.txt
printf '%s\n' "- **Author:** ${PR_AUTHOR}" >> /tmp/prompt.txt
printf '%s\n' "- **Changes:** ${DIFF_STATS}" >> /tmp/prompt.txt
printf '\n## PR Description\n' >> /tmp/prompt.txt
printf '%s\n' "${PR_BODY}" >> /tmp/prompt.txt
printf '\n## Question\n' >> /tmp/prompt.txt
printf '%s\n' "${USER_QUERY}" >> /tmp/prompt.txt
printf '\n---\n## PR Diff (for reference)\n' >> /tmp/prompt.txt
cat /tmp/pr.diff >> /tmp/prompt.txt
else
cat claude_md/ci_review_prompt.md > /tmp/prompt.txt
printf '\n## Pull Request Information\n' >> /tmp/prompt.txt
printf '%s\n' "- **Title:** ${PR_TITLE}" >> /tmp/prompt.txt
printf '%s\n' "- **Author:** ${PR_AUTHOR}" >> /tmp/prompt.txt
printf '%s\n' "- **Changes:** ${DIFF_STATS}" >> /tmp/prompt.txt
if [ "${IS_TRUNCATED}" = "true" ]; then
echo "- **Note:** Diff truncated due to size." >> /tmp/prompt.txt
fi
printf '\n## PR Description\n' >> /tmp/prompt.txt
printf '%s\n' "${PR_BODY}" >> /tmp/prompt.txt
if [ -n "${ADDITIONAL_CONTEXT}" ]; then
printf '\n## Additional Instructions from Reviewer\n' >> /tmp/prompt.txt
printf '%s\n' "${ADDITIONAL_CONTEXT}" >> /tmp/prompt.txt
fi
printf '\n---\n\n## Diff to Review\n\n' >> /tmp/prompt.txt
cat /tmp/pr.diff >> /tmp/prompt.txt
fi
- name: Build complexity-classifier prompt
if: steps.request.outputs.skip != 'true' && steps.request.outputs.type == 'review'
env:
BASE_SHA: ${{ steps.pr_info.outputs.base_sha }}
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
run: |
cp claude_md/ci_complexity_eval_prompt.md /tmp/complexity_prompt.txt
printf '\n---\n\n## Diff Summary\n\n' >> /tmp/complexity_prompt.txt
git diff --stat "${BASE_SHA}...${HEAD_SHA}" >> /tmp/complexity_prompt.txt
printf '\n## Changed Files\n\n' >> /tmp/complexity_prompt.txt
git diff --name-only "${BASE_SHA}...${HEAD_SHA}" >> /tmp/complexity_prompt.txt
printf '\n## Diff (truncated to 30KB)\n\n' >> /tmp/complexity_prompt.txt
head -c 30000 /tmp/pr.diff >> /tmp/complexity_prompt.txt
- name: Classify PR complexity (Claude)
if: >-
steps.request.outputs.skip != 'true' &&
steps.request.outputs.type == 'review' &&
inputs.provider == 'claude'
id: manual_classify_claude
uses: anthropics/claude-code-base-action@beta
with:
prompt_file: /tmp/complexity_prompt.txt
model: ${{ inputs.classifier_model }}
max_turns: "10"
timeout_minutes: "10"
allowed_tools: "View,GlobTool,GrepTool"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Pick thinking budget (Claude)
if: steps.request.outputs.skip != 'true' && inputs.provider == 'claude'
id: manual_budget_claude
env:
EXECUTION_FILE: ${{ steps.manual_classify_claude.outputs.execution_file }}
OVERRIDE: ${{ inputs.thinking_budget }}
COMMAND_TYPE: ${{ steps.request.outputs.type }}
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
if (process.env.COMMAND_TYPE !== 'review') {
core.setOutput('label', 'query');
core.setOutput('tokens', '8000');
return;
}
const override = (process.env.OVERRIDE || '').trim();
if (override && /^\d+$/.test(override)) {
const maxSafeBudget = 24000;
const requested = parseInt(override, 10);
const budget = Math.min(requested, maxSafeBudget).toString();
if (budget !== override) {
core.info(
`Clamping thinking_budget override from ${override} ` +
`to ${budget} so max_tokens can exceed thinking tokens.`
);
} else {
core.info(`Using thinking_budget override: ${budget}`);
}
core.setOutput('label', 'override');
core.setOutput('tokens', budget);
return;
}
let label = 'complex';
try {
const log = JSON.parse(
fs.readFileSync(process.env.EXECUTION_FILE, 'utf8'));
const result = log.find(message => message.type === 'result');
const text = result && result.result ?
result.result.trim().toLowerCase() :
'';
if (/\bsimple\b/.test(text) && !/\bcomplex\b/.test(text)) {
label = 'simple';
}
} catch (error) {
core.warning(
`Could not parse classifier output: ${error.message}`);
}
// Keep Claude's thinking budget below the action's effective
// max_tokens so the API has room for the final review text.
const budget = label === 'simple' ? '16000' : '24000';
core.info(
`Complexity classified as: ${label} ` +
`-> MAX_THINKING_TOKENS=${budget}`
);
core.setOutput('label', label);
core.setOutput('tokens', budget);
- name: Classify PR complexity (Codex)
if: >-
steps.request.outputs.skip != 'true' &&
steps.request.outputs.type == 'review' &&
inputs.provider == 'codex'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_HOME: /tmp/codex-home
CODEX_MODEL: ${{ inputs.selected_model != '' && inputs.selected_model || inputs.default_model }}
run: |
if [ -z "${OPENAI_API_KEY}" ]; then
echo "OPENAI_API_KEY not configured, skipping Codex review"
printf 'complex\n' > codex-complexity-output.txt
exit 0
fi
mkdir -p "${CODEX_HOME}"
set +e
codex exec \
-m "${CODEX_MODEL}" \
-c model_reasoning_effort="medium" \
--dangerously-bypass-approvals-and-sandbox \
--output-last-message codex-complexity-output.txt \
- < /tmp/complexity_prompt.txt > codex-complexity.log 2>&1
exit_code=$?
set -e
if [ "${exit_code}" -ne 0 ]; then
echo "Codex complexity classification failed with exit code ${exit_code}"
echo "Defaulting to complex review budget"
if [ -s codex-complexity.log ]; then
echo "Last 100 lines of codex-complexity.log:"
tail -100 codex-complexity.log
fi
printf 'complex\n' > codex-complexity-output.txt
fi
- name: Pick thinking budget (Codex)
if: steps.request.outputs.skip != 'true' && inputs.provider == 'codex'
id: manual_budget_codex
env:
OVERRIDE: ${{ inputs.thinking_budget }}
COMMAND_TYPE: ${{ steps.request.outputs.type }}
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
if (process.env.COMMAND_TYPE !== 'review') {
core.setOutput('label', 'query');
core.setOutput('effort', 'medium');
return;
}
const override = (process.env.OVERRIDE || '').trim();
if (override && /^\d+$/.test(override)) {
const budget = parseInt(override, 10);
const effort = budget >= 32000 ? 'xhigh' : 'high';
core.info(
`Using thinking_budget override: ${override} ` +
`-> model_reasoning_effort=${effort}`
);
core.setOutput('label', 'override');
core.setOutput('effort', effort);
return;
}
let label = 'complex';
try {
const text = fs.readFileSync(
'codex-complexity-output.txt',
'utf8'
).trim().toLowerCase();
if (/\bsimple\b/.test(text) && !/\bcomplex\b/.test(text)) {
label = 'simple';
}
} catch (error) {
core.warning(
`Could not parse classifier output: ${error.message}`);
}
const effort = label === 'simple' ? 'high' : 'xhigh';
core.info(
`Complexity classified as: ${label} ` +
`-> model_reasoning_effort=${effort}`
);
core.setOutput('label', label);
core.setOutput('effort', effort);
- name: Run Claude
if: steps.request.outputs.skip != 'true' && inputs.provider == 'claude'
id: manual_claude
env:
MAX_THINKING_TOKENS: ${{ steps.manual_budget_claude.outputs.tokens }}
CLAUDE_COMPLEXITY: ${{ steps.manual_budget_claude.outputs.label }}
uses: anthropics/claude-code-base-action@beta
with:
prompt_file: /tmp/prompt.txt
model: ${{ inputs.selected_model != '' && inputs.selected_model || inputs.default_model }}
max_turns: "300"
timeout_minutes: "60"
allowed_tools: "View,GlobTool,GrepTool,Write,Task"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Recover partial review on turn limit (Claude)
if: steps.request.outputs.skip != 'true' && inputs.provider == 'claude'
id: manual_recover_claude
env:
EXECUTION_FILE: ${{ steps.manual_claude.outputs.execution_file }}
COMMAND_TYPE: ${{ steps.request.outputs.type }}
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
if (process.env.COMMAND_TYPE === 'query') {
core.setOutput('needs_recovery', 'false');
return;
}
const log = JSON.parse(
fs.readFileSync(process.env.EXECUTION_FILE, 'utf8'));
const result = log.find(message => message.type === 'result');
const hitLimit = result && result.subtype === 'error_max_turns';
const hasFindings = fs.existsSync('review-findings.md');
core.setOutput('hit_limit', hitLimit ? 'true' : 'false');
core.setOutput('has_findings', hasFindings ? 'true' : 'false');
core.setOutput(
'needs_recovery',
hitLimit && hasFindings ? 'true' : 'false'
);
- name: Format partial findings (Claude)
if: >-
steps.request.outputs.skip != 'true' &&
inputs.provider == 'claude' &&
steps.manual_recover_claude.outputs.needs_recovery == 'true'
id: manual_format_recovery_claude
uses: anthropics/claude-code-base-action@beta
with:
prompt_file: claude_md/ci_recovery_prompt.md
model: ${{ inputs.recovery_model }}
max_turns: "10"
allowed_tools: "View,GlobTool,GrepTool"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Build review comment (Claude)
if: steps.request.outputs.skip != 'true' && inputs.provider == 'claude'
env:
EXECUTION_FILE: ${{ steps.manual_claude.outputs.execution_file }}
RECOVERY_FILE: ${{ steps.manual_format_recovery_claude.outputs.execution_file }}
CONCLUSION: ${{ steps.manual_claude.outputs.conclusion }}
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
REVIEWER: ${{ steps.request.outputs.reviewer }}
COMMAND_TYPE: ${{ steps.request.outputs.type }}
NEEDS_RECOVERY: ${{ steps.manual_recover_claude.outputs.needs_recovery }}
COMMENT_FILE: ${{ inputs.comment_file }}
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const parse = require('./.github/scripts/parse-claude-review.js');
const isReview = process.env.COMMAND_TYPE === 'review';
let execFile = process.env.EXECUTION_FILE;
if (process.env.NEEDS_RECOVERY === 'true') {
const recoveryFile = process.env.RECOVERY_FILE;
if (recoveryFile && fs.existsSync(recoveryFile)) {
execFile = recoveryFile;
}
}
const body = parse({
executionFile: execFile,
conclusion: process.env.CONCLUSION,
meta: {
trigger: 'manual',
headSha: process.env.HEAD_SHA,
reviewer: process.env.REVIEWER,
isQuery: !isReview,
isPartial: process.env.NEEDS_RECOVERY === 'true',
}
});
fs.writeFileSync(process.env.COMMENT_FILE, body);
- name: Run Codex
if: steps.request.outputs.skip != 'true' && inputs.provider == 'codex'
id: manual_codex
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_HOME: /tmp/codex-home
CODEX_MODEL: ${{ inputs.selected_model != '' && inputs.selected_model || inputs.default_model }}
CODEX_REASONING_EFFORT: ${{ steps.manual_budget_codex.outputs.effort }}
BASE_SHA: ${{ steps.pr_info.outputs.base_sha }}
PR_TITLE: ${{ steps.pr_info.outputs.pr_title }}
COMMAND_TYPE: ${{ steps.request.outputs.type }}
run: |
mkdir -p "${CODEX_HOME}"
set +e
if [ "${COMMAND_TYPE}" = "query" ]; then
codex exec \
-m "${CODEX_MODEL}" \
-c model_reasoning_effort="${CODEX_REASONING_EFFORT}" \
--dangerously-bypass-approvals-and-sandbox \
--output-last-message codex-review-output.md \
- < /tmp/prompt.txt > codex-review-execution.log 2>&1
else
codex exec review \
--base "${BASE_SHA}" \
--title "${PR_TITLE}" \
-m "${CODEX_MODEL}" \
-c model_reasoning_effort="${CODEX_REASONING_EFFORT}" \
--dangerously-bypass-approvals-and-sandbox \
--output-last-message codex-review-output.md \
- < /tmp/prompt.txt > codex-review-execution.log 2>&1
fi
exit_code=$?
set -e
echo "exit_code=${exit_code}" >> "${GITHUB_OUTPUT}"
echo "${exit_code}" > codex-review-exit-code.txt
- name: Recover partial review (Codex)
if: >-
steps.request.outputs.skip != 'true' &&
inputs.provider == 'codex' &&
steps.request.outputs.type == 'review' &&
steps.manual_codex.outputs.exit_code != '0' &&
hashFiles('review-findings.md') != ''
id: manual_recover_codex
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_HOME: /tmp/codex-home
CODEX_MODEL: ${{ inputs.selected_model != '' && inputs.selected_model || inputs.default_model }}
CODEX_REASONING_EFFORT: ${{ steps.manual_budget_codex.outputs.effort }}
run: |
set +e
codex exec \
-m "${CODEX_MODEL}" \
-c model_reasoning_effort="${CODEX_REASONING_EFFORT}" \
--dangerously-bypass-approvals-and-sandbox \
--output-last-message codex-review-recovery.md \
- < claude_md/ci_recovery_prompt.md > codex-review-recovery.log 2>&1
exit_code=$?
set -e
echo "exit_code=${exit_code}" >> "${GITHUB_OUTPUT}"
- name: Build review comment (Codex)
if: steps.request.outputs.skip != 'true' && inputs.provider == 'codex'
env:
RESPONSE_FILE: codex-review-output.md
RECOVERY_FILE: codex-review-recovery.md
FINDINGS_FILE: review-findings.md
LOG_FILE: codex-review-execution.log
EXIT_CODE: ${{ steps.manual_codex.outputs.exit_code }}
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
REVIEWER: ${{ steps.request.outputs.reviewer }}
COMMAND_TYPE: ${{ steps.request.outputs.type }}
USED_RECOVERY: ${{ steps.manual_recover_codex.outputs.exit_code == '0' && 'true' || 'false' }}
COMMENT_FILE: ${{ inputs.comment_file }}
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const parse = require('./.github/scripts/parse-codex-review.js');
const body = parse({
responseFile: process.env.RESPONSE_FILE,
recoveryFile: process.env.RECOVERY_FILE,
findingsFile: process.env.FINDINGS_FILE,
logFile: process.env.LOG_FILE,
exitCode: process.env.EXIT_CODE,
meta: {
trigger: 'manual',
headSha: process.env.HEAD_SHA,
reviewer: process.env.REVIEWER,
isQuery: process.env.COMMAND_TYPE !== 'review',
isPartial: process.env.USED_RECOVERY === 'true' ||
(!fs.existsSync(process.env.RESPONSE_FILE) &&
fs.existsSync(process.env.FINDINGS_FILE)),
}
});
fs.writeFileSync(process.env.COMMENT_FILE, body);
- name: Save PR number
if: steps.request.outputs.skip != 'true'
run: |
echo "${{ steps.pr_info.outputs.pr_number }}" > pr_number.txt
- name: Save trigger metadata
if: steps.request.outputs.skip != 'true'
env:
REVIEWER: ${{ steps.request.outputs.reviewer }}
COMMENT_ID: ${{ steps.request.outputs.comment_id }}
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
run: |
echo "manual" > trigger_type.txt
echo "${REVIEWER}" > reviewer.txt
echo "${COMMENT_ID}" > comment_id.txt
echo "${HEAD_SHA}" > head_sha.txt
- name: Upload review artifact
if: always() && steps.request.outputs.skip != 'true'
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.result_artifact_name }}
path: |
${{ inputs.comment_file }}
pr_number.txt
trigger_type.txt
reviewer.txt
comment_id.txt
head_sha.txt
if-no-files-found: ignore
retention-days: 1
- name: Upload execution log (Claude)
if: always() && steps.request.outputs.skip != 'true' && inputs.provider == 'claude'
uses: actions/upload-artifact@v4
with:
name: claude-review-execution-log
path: ${{ steps.manual_claude.outputs.execution_file }}
retention-days: 7
if-no-files-found: warn
- name: Upload recovery execution log (Claude)
if: >-
always() &&
steps.request.outputs.skip != 'true' &&
inputs.provider == 'claude' &&
steps.manual_recover_claude.outputs.needs_recovery == 'true'
uses: actions/upload-artifact@v4
with:
name: claude-review-recovery-log
path: ${{ steps.manual_format_recovery_claude.outputs.execution_file }}
retention-days: 7
if-no-files-found: ignore
- name: Upload review logs (Codex)
if: always() && steps.request.outputs.skip != 'true' && inputs.provider == 'codex'
uses: actions/upload-artifact@v4
with:
name: codex-review-logs
path: |
codex-review-execution.log
codex-complexity.log
codex-complexity-output.txt
codex-review-recovery.log
codex-review-output.md
codex-review-recovery.md
review-findings.md
codex-review-exit-code.txt
retention-days: 7
if-no-files-found: ignore