Improve onboarding quickstart and guidance #1520
Workflow file for this run
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-review-gate | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| on: | |
| pull_request: | |
| types: | |
| - opened | |
| - reopened | |
| - synchronize | |
| - edited | |
| - ready_for_review | |
| pull_request_review: | |
| types: | |
| - submitted | |
| - edited | |
| - dismissed | |
| pull_request_review_comment: | |
| types: | |
| - created | |
| - edited | |
| - deleted | |
| issue_comment: | |
| types: | |
| - created | |
| - edited | |
| - deleted | |
| workflow_dispatch: | |
| inputs: | |
| pr_number: | |
| description: Pull request number to validate | |
| required: true | |
| type: string | |
| permissions: | |
| actions: write | |
| contents: read | |
| issues: read | |
| pull-requests: read | |
| jobs: | |
| codex-review-gate: | |
| if: ${{ github.event_name != 'issue_comment' || github.event.issue.pull_request != null }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Validate codex review and comment resolution | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| core.notice('codex-review-gate enforcement is temporarily disabled for merge unblock.'); | |
| return; | |
| const dispatchPrNumberRaw = context.payload.inputs?.pr_number; | |
| const dispatchPrNumber = dispatchPrNumberRaw ? Number(dispatchPrNumberRaw) : null; | |
| const prNumber = context.payload.pull_request?.number | |
| ?? context.payload.issue?.number | |
| ?? (Number.isFinite(dispatchPrNumber) && dispatchPrNumber > 0 ? dispatchPrNumber : null); | |
| if (!prNumber) { | |
| core.setFailed('No pull request number found in event payload.'); | |
| return; | |
| } | |
| const parseDate = (value) => { | |
| const parsed = new Date(value ?? 0); | |
| return Number.isNaN(parsed.getTime()) ? new Date(0) : parsed; | |
| }; | |
| const isCodexReviewer = (login) => { | |
| if (!login) return false; | |
| const normalized = login.toLowerCase(); | |
| return normalized === 'chatgpt-codex-connector' || | |
| normalized === 'chatgpt-codex-connector[bot]'; | |
| }; | |
| const isCodexReviewRequest = (body) => /(^|\s)@codex\s+review(\b|$)/i.test(body ?? ''); | |
| if (context.eventName === 'issue_comment') { | |
| if (!context.payload.issue?.pull_request) { | |
| core.notice('Issue comment is not on a pull request; skipping.'); | |
| return; | |
| } | |
| const commentAuthor = context.payload.comment?.user?.login; | |
| const commentBody = context.payload.comment?.body ?? ''; | |
| const shouldDispatch = isCodexReviewer(commentAuthor) || isCodexReviewRequest(commentBody); | |
| if (!shouldDispatch) { | |
| core.notice('Issue comment is not a codex review request/response trigger; skipping.'); | |
| return; | |
| } | |
| const pullForDispatch = (await github.rest.pulls.get({ owner, repo, pull_number: prNumber })).data; | |
| const headRef = pullForDispatch.head?.ref; | |
| if (!headRef) { | |
| core.setFailed(`Unable to determine head ref for PR #${prNumber}.`); | |
| return; | |
| } | |
| await github.rest.actions.createWorkflowDispatch({ | |
| owner, | |
| repo, | |
| workflow_id: 'codex-review-gate.yml', | |
| ref: headRef, | |
| inputs: { | |
| pr_number: String(prNumber), | |
| }, | |
| }); | |
| core.notice(`Dispatched codex-review-gate recheck for PR #${prNumber} on ${headRef}.`); | |
| return; | |
| } | |
| const pull = (await github.rest.pulls.get({ owner, repo, pull_number: prNumber })).data; | |
| const prAuthor = pull.user?.login; | |
| if (!prAuthor) { | |
| core.setFailed('Unable to determine PR author.'); | |
| return; | |
| } | |
| const issueComments = await github.paginate(github.rest.issues.listComments, { | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| per_page: 100, | |
| }); | |
| const reviewRequests = issueComments.filter((comment) => { | |
| return comment.user?.login === prAuthor && isCodexReviewRequest(comment.body); | |
| }); | |
| const codexIssueComments = issueComments.filter((comment) => isCodexReviewer(comment.user?.login)); | |
| const reviews = await github.paginate(github.rest.pulls.listReviews, { | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| per_page: 100, | |
| }); | |
| const codexReviews = reviews.filter((review) => { | |
| if (!isCodexReviewer(review.user?.login)) return false; | |
| return review.state && review.state !== 'PENDING' && review.state !== 'DISMISSED'; | |
| }); | |
| const codexResponses = [ | |
| ...codexReviews.map((review) => ({ type: 'review', at: parseDate(review.submitted_at), state: review.state })), | |
| ...codexIssueComments.map((comment) => ({ type: 'comment', at: parseDate(comment.created_at), state: null })), | |
| ].sort((a, b) => a.at - b.at); | |
| if (codexResponses.length === 0) { | |
| core.setFailed('Missing codex response for initial automatic review cycle.'); | |
| return; | |
| } | |
| const unresolvedThreads = []; | |
| let cursor = null; | |
| while (true) { | |
| const query = ` | |
| query($owner: String!, $repo: String!, $number: Int!, $cursor: String) { | |
| repository(owner: $owner, name: $repo) { | |
| pullRequest(number: $number) { | |
| reviewThreads(first: 100, after: $cursor) { | |
| nodes { | |
| id | |
| isResolved | |
| comments(first: 100) { | |
| nodes { | |
| author { | |
| login | |
| } | |
| url | |
| } | |
| } | |
| } | |
| pageInfo { | |
| hasNextPage | |
| endCursor | |
| } | |
| } | |
| } | |
| } | |
| } | |
| `; | |
| const result = await github.graphql(query, { | |
| owner, | |
| repo, | |
| number: prNumber, | |
| cursor, | |
| }); | |
| const reviewThreads = result.repository.pullRequest.reviewThreads; | |
| for (const thread of reviewThreads.nodes) { | |
| if (thread.isResolved) continue; | |
| const codexComment = thread.comments.nodes.find((comment) => isCodexReviewer(comment.author?.login)); | |
| if (codexComment) { | |
| unresolvedThreads.push(codexComment.url || thread.id); | |
| } | |
| } | |
| if (!reviewThreads.pageInfo.hasNextPage) break; | |
| cursor = reviewThreads.pageInfo.endCursor; | |
| } | |
| if (unresolvedThreads.length > 0) { | |
| core.notice( | |
| `Codex escape clause active for PR #${prNumber}: ${unresolvedThreads.length} unresolved codex thread(s). Manual merge is allowed and auto-merge will stay disabled.`, | |
| ); | |
| return; | |
| } | |
| const firstCodexResponseAt = codexResponses[0].at; | |
| const latestCodexResponseAt = codexResponses[codexResponses.length - 1].at; | |
| const tenMinutesMs = 10 * 60 * 1000; | |
| const codexResponseAgeMs = Date.now() - latestCodexResponseAt.getTime(); | |
| if (codexResponseAgeMs > tenMinutesMs) { | |
| core.notice( | |
| `Codex escape clause active for PR #${prNumber}: latest codex response is older than 10 minutes. Manual merge is allowed and auto-merge will stay disabled.`, | |
| ); | |
| return; | |
| } | |
| const commits = await github.paginate(github.rest.pulls.listCommits, { | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| per_page: 100, | |
| }); | |
| const latestCommitAt = commits.reduce((latest, commit) => { | |
| const commitAt = parseDate(commit.commit?.committer?.date ?? commit.commit?.author?.date); | |
| return commitAt.getTime() > latest.getTime() ? commitAt : latest; | |
| }, parseDate(pull.created_at)); | |
| const hasSubsequentCycle = latestCommitAt.getTime() > firstCodexResponseAt.getTime(); | |
| const latestReviewRequest = [...reviewRequests] | |
| .sort((a, b) => parseDate(a.created_at) - parseDate(b.created_at)) | |
| .pop(); | |
| const latestReviewRequestAt = latestReviewRequest ? parseDate(latestReviewRequest.created_at) : null; | |
| if (hasSubsequentCycle) { | |
| if (!latestReviewRequestAt || latestReviewRequestAt.getTime() < latestCommitAt.getTime()) { | |
| core.setFailed('Missing "@codex review" comment after latest push. The first review cycle is automatic; subsequent cycles require an explicit request.'); | |
| return; | |
| } | |
| } | |
| if (latestReviewRequestAt && latestCodexResponseAt.getTime() < latestReviewRequestAt.getTime()) { | |
| core.setFailed('Missing codex response after latest "@codex review" request.'); | |
| return; | |
| } | |
| const relevantStart = latestReviewRequestAt ?? firstCodexResponseAt; | |
| const relevantCodexReviews = codexReviews.filter((review) => parseDate(review.submitted_at).getTime() >= relevantStart.getTime()); | |
| if (relevantCodexReviews.length > 0) { | |
| const latestCodexReview = relevantCodexReviews | |
| .sort((a, b) => parseDate(a.submitted_at) - parseDate(b.submitted_at)) | |
| .pop(); | |
| if (latestCodexReview.state === 'CHANGES_REQUESTED') { | |
| core.setFailed('Latest codex review requests changes.'); | |
| return; | |
| } | |
| } | |
| core.notice(`Codex review gate passed for PR #${prNumber}.`); |