# 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
