Skip to content

Improve onboarding quickstart and guidance #1520

Improve onboarding quickstart and guidance

Improve onboarding quickstart and guidance #1520

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}.`);