From dd3cc60d80a4be2871a0b8d673139386d9fb5f25 Mon Sep 17 00:00:00 2001 From: centdix Date: Mon, 30 Mar 2026 21:07:31 +0200 Subject: [PATCH] feat: add codex PR review workflow --- .github/codex/pr-review.prompt.md | 27 +++ .github/codex/review-output.schema.json | 74 +++++++ .github/workflows/codex-pr-review.yml | 252 ++++++++++++++++++++++++ 3 files changed, 353 insertions(+) create mode 100644 .github/codex/pr-review.prompt.md create mode 100644 .github/codex/review-output.schema.json create mode 100644 .github/workflows/codex-pr-review.yml diff --git a/.github/codex/pr-review.prompt.md b/.github/codex/pr-review.prompt.md new file mode 100644 index 0000000000..7afb814dd3 --- /dev/null +++ b/.github/codex/pr-review.prompt.md @@ -0,0 +1,27 @@ +You are reviewing a GitHub pull request for this repository. + +Review policy: +- Read `CLAUDE.md` before reviewing code. +- Only report issues you are confident are real and introduced by this pull request. +- Focus on bugs, security problems, and clear `CLAUDE.md` violations. +- Do not report style nits, speculative concerns, pre-existing issues, or problems that a normal linter/typechecker would obviously catch. +- Keep the review high signal. If there is no clear issue, return no findings. + +Repository context: +- Read `./.github/codex/pr-review-context.md` for the PR metadata and the exact diff commands to use. +- Review only the changes introduced by this PR. +- Read additional files only when the diff is not enough to validate a finding. +- Do not modify any files. + +Output requirements: +- Return JSON that matches the provided schema exactly. +- `summary` must be a short overall review summary. +- `reproduction_instructions` must be a short descriptive paragraph for a tester explaining how to navigate the app to observe the change. Do not make it a numbered list. If the diff is not enough to infer this safely, say that plainly. +- `findings` must contain only high-signal issues. Use an empty array if there are no such issues. + +Finding requirements: +- Use a changed file path from this PR. +- Set `line` to the exact right-side line number on the PR head when you are confident it is part of the diff. +- If you cannot map a finding to a changed line with confidence, leave `line` as `null`. +- Keep each finding concise and specific. +- Prefer at most 10 findings. diff --git a/.github/codex/review-output.schema.json b/.github/codex/review-output.schema.json new file mode 100644 index 0000000000..b93bfe82ee --- /dev/null +++ b/.github/codex/review-output.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "CodexPullRequestReview", + "type": "object", + "additionalProperties": false, + "required": [ + "summary", + "reproduction_instructions", + "findings" + ], + "properties": { + "summary": { + "type": "string", + "maxLength": 4000 + }, + "reproduction_instructions": { + "type": "string", + "maxLength": 4000 + }, + "findings": { + "type": "array", + "maxItems": 10, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "title", + "body", + "path", + "line", + "severity", + "reason" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 200 + }, + "body": { + "type": "string", + "maxLength": 2000 + }, + "path": { + "type": "string", + "maxLength": 500 + }, + "line": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "severity": { + "type": "string", + "enum": [ + "high", + "medium", + "low" + ] + }, + "reason": { + "type": "string", + "enum": [ + "bug", + "security", + "claude_md" + ] + } + } + } + } + } +} diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml new file mode 100644 index 0000000000..fd705813a3 --- /dev/null +++ b/.github/workflows/codex-pr-review.yml @@ -0,0 +1,252 @@ +name: Codex Auto Review + +on: + pull_request: + types: [ready_for_review, opened] + +concurrency: + group: codex-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + codex-review: + runs-on: ubuntu-latest + if: (github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true) && github.event.pull_request.head.repo.fork == false + permissions: + contents: read + pull-requests: write + outputs: + review_json: ${{ steps.run_codex.outputs.final-message }} + steps: + - name: Check Codex configuration + id: codex_config + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + if [ -n "$OPENAI_API_KEY" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "OPENAI_API_KEY is not configured; skipping Codex review." + fi + + - name: Checkout repository + if: steps.codex_config.outputs.enabled == 'true' + uses: actions/checkout@v5 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/merge + fetch-depth: 1 + + - name: Pre-fetch base and head refs for the PR + if: steps.codex_config.outputs.enabled == 'true' + env: + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + git fetch --no-tags origin \ + "$PR_BASE_REF" \ + "+refs/pull/$PR_NUMBER/head" + + - name: Write Codex review context + if: steps.codex_config.outputs.enabled == 'true' + env: + PR_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body || '' }} + run: | + mkdir -p .github/codex + node <<'NODE' + const fs = require('fs'); + const lines = [ + `Repository: ${process.env.PR_REPOSITORY}`, + `PR number: ${process.env.PR_NUMBER}`, + `Base SHA: ${process.env.PR_BASE_SHA}`, + `Head SHA: ${process.env.PR_HEAD_SHA}`, + '', + 'PR title:', + process.env.PR_TITLE || '(empty)', + '', + 'PR body:', + process.env.PR_BODY || '(empty)', + '', + 'Changed commits command:', + `git log --oneline ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`, + '', + 'Changed files command:', + `git diff --stat ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`, + '', + 'Full review diff command:', + `git diff --unified=0 ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}` + ]; + fs.writeFileSync('.github/codex/pr-review-context.md', `${lines.join('\n')}\n`); + NODE + + - name: Run Codex review + if: steps.codex_config.outputs.enabled == 'true' + id: run_codex + uses: openai/codex-action@v1 + with: + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + prompt-file: .github/codex/pr-review.prompt.md + output-schema-file: .github/codex/review-output.schema.json + model: gpt-5.4 + effort: xhigh + sandbox: read-only + safety-strategy: drop-sudo + + post-review: + runs-on: ubuntu-latest + needs: codex-review + if: needs.codex-review.outputs.review_json != '' + permissions: + pull-requests: write + steps: + - name: Post Codex review + uses: actions/github-script@v7 + env: + CODEX_REVIEW_JSON: ${{ needs.codex-review.outputs.review_json }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + with: + github-token: ${{ github.token }} + script: | + const raw = process.env.CODEX_REVIEW_JSON?.trim(); + if (!raw) { + core.info('No Codex review payload found.'); + return; + } + + let payload; + try { + payload = JSON.parse(raw); + } catch (error) { + core.setFailed(`Codex review payload was not valid JSON: ${error.message}`); + return; + } + + const summary = typeof payload.summary === 'string' && payload.summary.trim() + ? payload.summary.trim() + : 'No high-signal issues found.'; + const reproduction = typeof payload.reproduction_instructions === 'string' && payload.reproduction_instructions.trim() + ? payload.reproduction_instructions.trim() + : 'Not enough UI context in the diff to provide reproduction instructions.'; + const findings = Array.isArray(payload.findings) ? payload.findings : []; + + const parseChangedLines = (patch) => { + const changedLines = new Set(); + if (!patch) { + return changedLines; + } + + let nextNewLine = null; + for (const line of patch.split('\n')) { + const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/); + if (hunk) { + nextNewLine = Number.parseInt(hunk[1], 10); + continue; + } + if (nextNewLine === null || !line) { + continue; + } + if (line.startsWith('+') && !line.startsWith('+++')) { + changedLines.add(nextNewLine); + nextNewLine += 1; + continue; + } + if (line.startsWith('-') && !line.startsWith('---')) { + continue; + } + if (!line.startsWith('\\')) { + nextNewLine += 1; + } + } + + return changedLines; + }; + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + per_page: 100 + }); + + const changedLinesByPath = new Map( + files.map((file) => [file.filename, parseChangedLines(file.patch)]) + ); + + const reviewComments = []; + const summaryOnlyFindings = []; + + for (const finding of findings) { + const title = typeof finding.title === 'string' ? finding.title.trim() : ''; + const body = typeof finding.body === 'string' ? finding.body.trim() : ''; + const path = typeof finding.path === 'string' ? finding.path.trim() : ''; + const severity = typeof finding.severity === 'string' ? finding.severity.trim() : 'medium'; + const reason = typeof finding.reason === 'string' ? finding.reason.trim() : 'bug'; + const line = Number.isInteger(finding.line) ? finding.line : null; + + if (!title || !body) { + continue; + } + + const formattedBody = `[${severity}][${reason}] ${title}\n\n${body}`; + const changedLines = changedLinesByPath.get(path); + if (path && line !== null && changedLines?.has(line)) { + reviewComments.push({ + path, + line, + side: 'RIGHT', + body: formattedBody + }); + } else { + summaryOnlyFindings.push({ + path, + line, + severity, + reason, + title, + body + }); + } + } + + const bodyLines = [ + '## Codex Review', + '', + summary + ]; + + if (summaryOnlyFindings.length > 0) { + bodyLines.push('', '### Additional findings'); + summaryOnlyFindings.forEach((finding, index) => { + const location = finding.path + ? `${finding.path}${finding.line ? `:${finding.line}` : ''}` + : 'general'; + bodyLines.push( + '', + `${index + 1}. [${finding.severity}][${finding.reason}] ${location} - ${finding.title}`, + '', + finding.body + ); + }); + } + + bodyLines.push('', '### Reproduction instructions', '', reproduction); + + const reviewPayload = { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + commit_id: process.env.PR_HEAD_SHA, + event: 'COMMENT', + body: bodyLines.join('\n') + }; + + if (reviewComments.length > 0) { + reviewPayload.comments = reviewComments; + } + + await github.rest.pulls.createReview(reviewPayload);