mirror of
https://github.com/facebook/rocksdb.git
synced 2026-07-07 14:47:40 +08:00
46ce1a03a9
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
1502 lines
60 KiB
YAML
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
|