source value 8 is obsolete #6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Codex PR Review | |
| on: | |
| pull_request_target: | |
| types: [opened, reopened] | |
| issue_comment: | |
| types: [created] | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| concurrency: | |
| group: codex-review-${{ github.event.pull_request.number || github.event.issue.number || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| gate-report: | |
| if: | | |
| (github.event_name != 'issue_comment' || ( | |
| github.event.issue.pull_request && | |
| github.event.comment.body == '#codex-review' | |
| )) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Print pre-gate decision | |
| shell: bash | |
| run: | | |
| echo "Pre-gate report" | |
| echo " event_name: ${{ github.event_name }}" | |
| echo " issue_comment_on_pr: ${{ github.event.issue.pull_request && 'true' || 'false' }}" | |
| echo " comment_body: ${{ github.event.comment.body || '' }}" | |
| echo " event_author_association: ${{ github.event.pull_request.author_association || github.event.comment.author_association || '' }}" | |
| resolve-pr-context: | |
| needs: gate-report | |
| if: needs.gate-report.result == 'success' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| pull_number: ${{ steps.resolve.outputs.pull_number }} | |
| head_ref: ${{ steps.resolve.outputs.head_ref }} | |
| base_ref: ${{ steps.resolve.outputs.base_ref }} | |
| head_sha: ${{ steps.resolve.outputs.head_sha }} | |
| base_sha: ${{ steps.resolve.outputs.base_sha }} | |
| pr_body: ${{ steps.resolve.outputs.pr_body }} | |
| author_association: ${{ steps.resolve.outputs.author_association }} | |
| milestone_number: ${{ steps.resolve.outputs.milestone_number }} | |
| milestone_title: ${{ steps.resolve.outputs.milestone_title }} | |
| should_run_downstream: ${{ steps.resolve.outputs.should_run_downstream }} | |
| gate_reason: ${{ steps.resolve.outputs.gate_reason }} | |
| steps: | |
| - name: Resolve PR context | |
| id: resolve | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const eventName = context.eventName; | |
| const expectedCommand = '#codex-review'; | |
| const expectedAssociations = ['MEMBER', 'OWNER']; | |
| const isIssueComment = eventName === 'issue_comment'; | |
| const isPullRequestComment = Boolean(context.payload.issue?.pull_request); | |
| const receivedCommentBody = context.payload.comment?.body ?? ''; | |
| const receivedCommentAuthorAssociation = context.payload.comment?.author_association ?? ''; | |
| const commentCommandMatches = receivedCommentBody === expectedCommand; | |
| let pr = context.payload.pull_request; | |
| if (!pr && eventName === 'issue_comment') { | |
| if (!context.payload.issue?.pull_request) { | |
| core.setFailed('issue_comment is not on a pull request'); | |
| return; | |
| } | |
| const pull_number = context.issue.number; | |
| const response = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number, | |
| }); | |
| pr = response.data; | |
| } | |
| if (!pr) { | |
| core.setFailed('Unable to resolve pull request context from event payload'); | |
| return; | |
| } | |
| const prAuthorAssociation = pr.author_association || ''; | |
| const headRef = pr.head?.ref || ''; | |
| const baseRef = pr.base?.ref || ''; | |
| const isMilestoneBranch = (ref) => ref.startsWith('milestone/') || ref.startsWith('milestones/'); | |
| const milestoneBranchGatePassed = isMilestoneBranch(headRef) || isMilestoneBranch(baseRef); | |
| const commandGatePassed = !isIssueComment || (isPullRequestComment && commentCommandMatches); | |
| const shouldRunDownstream = commandGatePassed && milestoneBranchGatePassed; | |
| let gateReason = 'ok'; | |
| if (isIssueComment && !isPullRequestComment) { | |
| gateReason = 'not_pr_comment'; | |
| } else if (isIssueComment && !commentCommandMatches) { | |
| gateReason = 'bad_command'; | |
| } else if (!milestoneBranchGatePassed) { | |
| gateReason = 'non_milestone_branch'; | |
| } | |
| core.info(`Gate check: expected event trigger command for issue_comment "${expectedCommand}", received "${receivedCommentBody}"`); | |
| core.info(`Gate check: expected issue_comment on PR=true, received ${String(isPullRequestComment)}`); | |
| core.info(`Gate check: expected comment author association in [${expectedAssociations.join(', ')}], received "${receivedCommentAuthorAssociation}"`); | |
| core.info(`Gate check: expected PR author association in [${expectedAssociations.join(', ')}], received "${prAuthorAssociation}"`); | |
| core.info(`Gate check: expected head/base ref under milestone/*, received head_ref="${headRef}", base_ref="${baseRef}", milestoneBranchGatePassed=${String(milestoneBranchGatePassed)}`); | |
| core.info(`Gate evaluation: commandGatePassed=${String(commandGatePassed)}, shouldRunDownstream=${String(shouldRunDownstream)}, gateReason="${gateReason}"`); | |
| core.setOutput('pull_number', String(pr.number)); | |
| core.setOutput('head_ref', headRef); | |
| core.setOutput('base_ref', baseRef); | |
| core.setOutput('head_sha', pr.head?.sha || ''); | |
| core.setOutput('base_sha', pr.base?.sha || ''); | |
| core.setOutput('pr_body', pr.body || ''); | |
| core.setOutput('author_association', prAuthorAssociation); | |
| core.setOutput('milestone_number', pr.milestone?.number ? String(pr.milestone.number) : ''); | |
| core.setOutput('milestone_title', pr.milestone?.title || ''); | |
| core.setOutput('should_run_downstream', String(shouldRunDownstream)); | |
| core.setOutput('gate_reason', gateReason); | |
| rebase-check: | |
| needs: resolve-pr-context | |
| if: needs.resolve-pr-context.outputs.should_run_downstream == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout PR head | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ needs.resolve-pr-context.outputs.head_sha }} | |
| fetch-depth: 0 | |
| - name: Rebase check bypass for milestone head branches | |
| if: | | |
| startsWith(needs.resolve-pr-context.outputs.head_ref, 'milestone/') || | |
| startsWith(needs.resolve-pr-context.outputs.head_ref, 'milestones/') | |
| shell: bash | |
| run: | | |
| echo "Bypassing rebase-check for milestone head branch: ${{ needs.resolve-pr-context.outputs.head_ref }}" | |
| - name: Ensure PR is rebased onto base | |
| if: | | |
| !startsWith(needs.resolve-pr-context.outputs.head_ref, 'milestone/') && | |
| !startsWith(needs.resolve-pr-context.outputs.head_ref, 'milestones/') | |
| shell: bash | |
| run: | | |
| git fetch --no-tags origin ${{ needs.resolve-pr-context.outputs.base_sha }} | |
| if ! git merge-base --is-ancestor \ | |
| ${{ needs.resolve-pr-context.outputs.base_sha }} \ | |
| ${{ needs.resolve-pr-context.outputs.head_sha }}; then | |
| echo "PR head is not rebased onto base." | |
| echo "Base SHA: ${{ needs.resolve-pr-context.outputs.base_sha }}" | |
| echo "Head SHA: ${{ needs.resolve-pr-context.outputs.head_sha }}" | |
| exit 1 | |
| fi | |
| codex-review: | |
| needs: [resolve-pr-context, rebase-check] | |
| if: needs.resolve-pr-context.outputs.should_run_downstream == 'true' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| summary: ${{ steps.parse_review.outputs.summary }} | |
| findings_json: ${{ steps.parse_review.outputs.findings_json }} | |
| steps: | |
| - name: Checkout PR merge ref | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ needs.resolve-pr-context.outputs.head_sha }} | |
| fetch-depth: 0 | |
| - name: Debug workflow refs | |
| shell: bash | |
| run: | | |
| echo "GITHUB_WORKFLOW_REF=$GITHUB_WORKFLOW_REF" | |
| echo "GITHUB_REF=$GITHUB_REF" | |
| echo "GITHUB_SHA=$GITHUB_SHA" | |
| echo "--- workflow file (first 120 lines) ---" | |
| sed -n '1,120p' .github/workflows/codex-review.yml | |
| - name: Fetch PR base and head | |
| shell: bash | |
| run: | | |
| git fetch --no-tags origin ${{ needs.resolve-pr-context.outputs.base_sha }} | |
| git fetch --no-tags origin ${{ needs.resolve-pr-context.outputs.head_sha }} | |
| - name: Resolve migration guide | |
| id: guide | |
| shell: bash | |
| run: | | |
| num="${{ needs.resolve-pr-context.outputs.milestone_number }}" | |
| if [ -z "$num" ]; then | |
| echo "PR has no milestone set." | |
| exit 1 | |
| fi | |
| title="${{ needs.resolve-pr-context.outputs.milestone_title }}" | |
| if [ -z "$title" ]; then | |
| echo "PR has no milestone title set." | |
| exit 1 | |
| fi | |
| # Sanitize title for filename matching | |
| safe=$(echo "$title" | sed -E 's/[^A-Za-z0-9._-]+/_/g' | sed -E 's/^_+|_+$//g') | |
| glob="docs/migrations/${safe}.md" | |
| matches=$(ls $glob 2>/dev/null || true) | |
| count=$(echo "$matches" | sed '/^$/d' | wc -l | tr -d ' ') | |
| if [ "$count" -ne 1 ]; then | |
| echo "Expected exactly one migration guide for milestone title: $title" | |
| echo "Sanitized title: $safe" | |
| echo "Attempted match: $glob" | |
| echo "Match count: $count" | |
| if [ -n "$matches" ]; then | |
| echo "Matches:" | |
| echo "$matches" | |
| fi | |
| exit 1 | |
| fi | |
| echo "GUIDE_PATH=$matches" >> "$GITHUB_ENV" | |
| - name: Build Codex prompt | |
| shell: bash | |
| run: | | |
| cat .github/codex/prompts/review.md > /tmp/codex_prompt.md | |
| echo "" >> /tmp/codex_prompt.md | |
| echo "AGENTS instructions (from HEAD):" >> /tmp/codex_prompt.md | |
| cat AGENTS.md >> /tmp/codex_prompt.md | |
| echo "" >> /tmp/codex_prompt.md | |
| echo "Migration guide (from HEAD): $GUIDE_PATH" >> /tmp/codex_prompt.md | |
| cat "$GUIDE_PATH" >> /tmp/codex_prompt.md | |
| echo "" >> /tmp/codex_prompt.md | |
| echo "PR description (from GitHub):" >> /tmp/codex_prompt.md | |
| cat <<'EOF' >> /tmp/codex_prompt.md | |
| ${{ needs.resolve-pr-context.outputs.pr_body }} | |
| EOF | |
| echo "" >> /tmp/codex_prompt.md | |
| echo "Review only the PR changes using:" >> /tmp/codex_prompt.md | |
| echo "git diff ${{ needs.resolve-pr-context.outputs.base_sha }}...${{ needs.resolve-pr-context.outputs.head_sha }}" >> /tmp/codex_prompt.md | |
| echo "" >> /tmp/codex_prompt.md | |
| echo "For any inline comment, first find the exact line number with: nl -ba <file>." >> /tmp/codex_prompt.md | |
| - name: Run Codex review | |
| id: codex | |
| uses: openai/codex-action@v1 | |
| with: | |
| openai-api-key: ${{ secrets.OPENAI_API_KEY }} | |
| prompt-file: /tmp/codex_prompt.md | |
| sandbox: read-only | |
| - name: Parse Codex review output | |
| id: parse_review | |
| uses: actions/github-script@v7 | |
| env: | |
| REVIEW_BODY: ${{ steps.codex.outputs.final-message }} | |
| with: | |
| script: | | |
| const raw = process.env.REVIEW_BODY || ''; | |
| let summary = raw || 'Codex review produced no output.'; | |
| let comments = []; | |
| try { | |
| const parsed = JSON.parse(raw); | |
| summary = parsed.summary || summary; | |
| comments = Array.isArray(parsed.comments) ? parsed.comments : []; | |
| } catch (err) { | |
| core.setFailed(`Codex output is not valid JSON: ${err.message}`); | |
| return; | |
| } | |
| const findings = comments | |
| .filter((c) => c && c.path && Number.isInteger(c.line)) | |
| .map((c, idx) => ({ | |
| finding_index: idx, | |
| path: c.path, | |
| line: c.line, | |
| body: c.body || c.summary || c.message || 'Codex review comment.', | |
| })); | |
| core.info(`Parsed findings: ${findings.length}`); | |
| core.setOutput('summary', summary); | |
| core.setOutput('findings_json', JSON.stringify(findings)); | |
| codex-match-and-post: | |
| needs: [resolve-pr-context, rebase-check, codex-review] | |
| if: needs.resolve-pr-context.outputs.should_run_downstream == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Fetch unresolved review threads | |
| id: unresolved | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const pullNumber = Number('${{ needs.resolve-pr-context.outputs.pull_number }}'); | |
| const query = ` | |
| query($owner: String!, $repo: String!, $number: Int!) { | |
| repository(owner: $owner, name: $repo) { | |
| pullRequest(number: $number) { | |
| reviewThreads(first: 100) { | |
| nodes { | |
| id | |
| isResolved | |
| comments(first: 100) { | |
| nodes { | |
| databaseId | |
| body | |
| path | |
| line | |
| originalLine | |
| author { | |
| login | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| `; | |
| const result = await github.graphql(query, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| number: pullNumber, | |
| }); | |
| const nodes = result.repository.pullRequest.reviewThreads.nodes || []; | |
| const unresolved = nodes | |
| .filter((t) => !t.isResolved) | |
| .map((t) => { | |
| const comments = t.comments?.nodes || []; | |
| const last = comments[comments.length - 1]; | |
| const first = comments[0]; | |
| return { | |
| thread_id: t.id, | |
| comment_id: last?.databaseId || first?.databaseId || null, | |
| path: last?.path || first?.path || '', | |
| line: last?.line ?? last?.originalLine ?? first?.line ?? first?.originalLine ?? null, | |
| body: last?.body || '', | |
| author: last?.author?.login || 'unknown', | |
| }; | |
| }) | |
| .filter((t) => t.comment_id !== null); | |
| core.info(`Unresolved threads loaded: ${unresolved.length}`); | |
| core.setOutput('threads_json', JSON.stringify(unresolved)); | |
| - name: Build Codex matching prompt | |
| shell: bash | |
| run: | | |
| cat > /tmp/codex_match_prompt.md <<'EOF' | |
| You are matching new Codex findings against existing unresolved PR review threads. | |
| Goal: | |
| - For each new finding, decide whether it should: | |
| 1) open a new thread (`new_thread`) | |
| 2) reply to an existing unresolved thread (`reply_existing`) | |
| Matching guidance: | |
| - Prefer reply_existing when the unresolved thread and new finding are similar or related in intent. | |
| - Consider path, nearby line, and semantic meaning of the issue. | |
| - If uncertain, choose new_thread. | |
| Output strict JSON only: | |
| { | |
| "actions": [ | |
| { | |
| "finding_index": 0, | |
| "action": "new_thread | reply_existing", | |
| "target_comment_id": 123456789, | |
| "reason": "short reason" | |
| } | |
| ] | |
| } | |
| Rules: | |
| - Include exactly one action per finding_index. | |
| - For action=new_thread, omit target_comment_id. | |
| - For action=reply_existing, include target_comment_id from unresolved threads input. | |
| EOF | |
| { | |
| echo "" | |
| echo "New findings JSON:" | |
| echo '${{ needs.codex-review.outputs.findings_json }}' | |
| echo "" | |
| echo "Unresolved review threads JSON:" | |
| echo '${{ steps.unresolved.outputs.threads_json }}' | |
| } >> /tmp/codex_match_prompt.md | |
| - name: Run Codex matcher | |
| id: codex_match | |
| uses: openai/codex-action@v1 | |
| with: | |
| openai-api-key: ${{ secrets.OPENAI_API_KEY }} | |
| prompt-file: /tmp/codex_match_prompt.md | |
| sandbox: read-only | |
| - name: Post matched review updates | |
| uses: actions/github-script@v7 | |
| env: | |
| SUMMARY: ${{ needs.codex-review.outputs.summary }} | |
| FINDINGS_JSON: ${{ needs.codex-review.outputs.findings_json }} | |
| MATCH_RAW: ${{ steps.codex_match.outputs.final-message }} | |
| PULL_NUMBER: ${{ needs.resolve-pr-context.outputs.pull_number }} | |
| with: | |
| script: | | |
| const summary = process.env.SUMMARY || 'Codex review completed.'; | |
| const pullNumber = Number(process.env.PULL_NUMBER); | |
| const findings = JSON.parse(process.env.FINDINGS_JSON || '[]'); | |
| let actions = []; | |
| try { | |
| const parsed = JSON.parse(process.env.MATCH_RAW || '{}'); | |
| actions = Array.isArray(parsed.actions) ? parsed.actions : []; | |
| } catch (err) { | |
| core.warning(`Codex matcher output parse failed. Falling back to new_thread for all findings: ${err.message}`); | |
| } | |
| const actionByIndex = new Map(); | |
| for (const a of actions) { | |
| if (!a || !Number.isInteger(a.finding_index)) continue; | |
| actionByIndex.set(a.finding_index, a); | |
| } | |
| const newReviewComments = []; | |
| const groupedReplies = new Map(); | |
| for (const finding of findings) { | |
| const action = actionByIndex.get(finding.finding_index); | |
| const shouldReply = action && | |
| action.action === 'reply_existing' && | |
| Number.isInteger(action.target_comment_id); | |
| if (shouldReply) { | |
| const key = String(action.target_comment_id); | |
| const existing = groupedReplies.get(key) || []; | |
| existing.push({ | |
| body: finding.body, | |
| path: finding.path, | |
| line: finding.line, | |
| reason: action.reason || '', | |
| }); | |
| groupedReplies.set(key, existing); | |
| core.info(`Finding ${finding.finding_index} matched existing comment ${key}`); | |
| } else { | |
| newReviewComments.push({ | |
| path: finding.path, | |
| line: finding.line, | |
| side: 'RIGHT', | |
| body: finding.body, | |
| }); | |
| core.info(`Finding ${finding.finding_index} treated as new_thread`); | |
| } | |
| } | |
| for (const [commentId, matchedFindings] of groupedReplies.entries()) { | |
| const lines = matchedFindings.map((f) => `- \`${f.path}:${f.line}\` ${f.body}`); | |
| const reasons = matchedFindings | |
| .map((f) => f.reason) | |
| .filter(Boolean) | |
| .map((r) => `- ${r}`); | |
| const replyBody = [ | |
| 'Codex follow-up: this issue still appears related to this unresolved thread.', | |
| '', | |
| '**Current findings**', | |
| ...lines, | |
| reasons.length ? '' : null, | |
| reasons.length ? '**Matching rationale**' : null, | |
| ...reasons, | |
| ].filter(Boolean).join('\n'); | |
| await github.rest.pulls.createReplyForReviewComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pullNumber, | |
| comment_id: Number(commentId), | |
| body: replyBody, | |
| }); | |
| } | |
| if (newReviewComments.length > 0) { | |
| try { | |
| await github.rest.pulls.createReview({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pullNumber, | |
| event: 'REQUEST_CHANGES', | |
| body: summary, | |
| comments: newReviewComments, | |
| }); | |
| } catch (err) { | |
| const message = err?.message || ''; | |
| const isUnresolvedLine = err?.status === 422 && message.includes('Line could not be resolved'); | |
| if (!isUnresolvedLine) { | |
| throw err; | |
| } | |
| core.warning(`Inline review comment could not be resolved. Falling back to body-only review: ${message}`); | |
| const fallbackFindings = newReviewComments.map((c) => `- \`${c.path}:${c.line}\` ${c.body}`); | |
| const fallbackBody = [ | |
| summary, | |
| '', | |
| 'Inline comment positions could not be resolved for one or more findings, so they are listed below instead:', | |
| '', | |
| '**Unresolved inline findings**', | |
| ...fallbackFindings, | |
| ].join('\n'); | |
| await github.rest.pulls.createReview({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pullNumber, | |
| event: 'REQUEST_CHANGES', | |
| body: fallbackBody, | |
| comments: [], | |
| }); | |
| } | |
| return; | |
| } | |
| if (findings.length > 0) { | |
| const matchedOnlySummary = [ | |
| summary, | |
| '', | |
| 'No new inline findings were identified in this run.', | |
| 'Existing unresolved issues remain, and follow-up comments were posted on related existing review threads.', | |
| ].join('\n'); | |
| await github.rest.pulls.createReview({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pullNumber, | |
| event: 'COMMENT', | |
| body: matchedOnlySummary, | |
| comments: [], | |
| }); | |
| return; | |
| } | |
| if (findings.length === 0) { | |
| await github.rest.pulls.createReview({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pullNumber, | |
| event: 'APPROVE', | |
| body: summary, | |
| comments: [], | |
| }); | |
| } |