diff --git a/.github/scripts/pr-review/prepare.mjs b/.github/scripts/pr-review/prepare.mjs index c283f1d..12c11cc 100644 --- a/.github/scripts/pr-review/prepare.mjs +++ b/.github/scripts/pr-review/prepare.mjs @@ -52,6 +52,7 @@ const maxDiffBytes = positiveInteger("MAX_DIFF_BYTES"); const diffReadBufferBytes = maxDiffBytes + 64 * 1024; const automaticMaxDiffBytes = positiveInteger("AUTOMATIC_MAX_DIFF_BYTES"); const largeDiffApproved = required("LARGE_DIFF_APPROVED") === "true"; +const privateRepository = required("PRIVATE_REPOSITORY") === "true"; const chunkTargetBytes = positiveInteger("CHUNK_TARGET_BYTES"); const ledgerPath = path.join(stateDir, "review-ledger.json"); const generationsDir = path.join(stateDir, "generations"); @@ -97,7 +98,7 @@ if (fullDiff.length > maxDiffBytes) { `Pull-request diff is ${fullDiff.length} bytes; the configured total limit is ${maxDiffBytes} bytes.`, ); } -if (!largeDiffApproved && fullDiff.length > automaticMaxDiffBytes) { +if (!privateRepository && !largeDiffApproved && fullDiff.length > automaticMaxDiffBytes) { throw new Error( `Pull-request diff is ${fullDiff.length} bytes; automatic reviews are limited to ${automaticMaxDiffBytes} bytes. A repository admin must comment @codex review approve ${headSha} on this PR to review this head.`, ); diff --git a/.github/scripts/pr-review/test.mjs b/.github/scripts/pr-review/test.mjs index a69337c..705d2ff 100644 --- a/.github/scripts/pr-review/test.mjs +++ b/.github/scripts/pr-review/test.mjs @@ -154,6 +154,8 @@ assert.match(workflowSource, /^ review:\n(?:(?!^ \S)[\s\S])*?^ REVIEW_MAX assert.match(workflowSource, /^ review:\n(?:(?!^ \S)[\s\S])*?^ REVIEW_AUTOMATIC_MAX_DIFF_BYTES: '5000000'$/m); assert.match(workflowSource, /AUTOMATIC_MAX_DIFF_BYTES: \$\{\{ env\.REVIEW_AUTOMATIC_MAX_DIFF_BYTES \}\}/); assert.match(workflowSource, /LARGE_DIFF_APPROVED: \$\{\{ needs\.resolve\.outputs\.large_diff_approved \}\}/); +assert.match(workflowSource, /PRIVATE_REPOSITORY: \$\{\{ needs\.resolve\.outputs\.private_repository \}\}/); +assert.match(workflowSource, /private_repository', String\(pr\.base\.repo\?\.private === true\)/); assert.match(workflowSource, /getCollaboratorPermissionLevel/); assert.match(workflowSource, /^ publish:\n(?:(?!^ \S)[\s\S])*?^ REVIEW_MODEL: gpt-6-sol$/m); assert.match(workflowSource, /^ publish:\n(?:(?!^ \S)[\s\S])*?^ REVIEW_EFFORT: low$/m); @@ -526,7 +528,7 @@ try { }); assert.equal(plainPatch.status, 0); assert.ok(plainPatch.stdout.length < 1000); - const prepareBinary = (approved) => spawnSync(process.execPath, [ + const prepareBinary = (approved, privateRepo = false, hardMax = "20000") => spawnSync(process.execPath, [ path.join(path.dirname(new URL(import.meta.url).pathname), "prepare.mjs"), ], { cwd: binaryRepo, @@ -538,9 +540,10 @@ try { PR_BASE_SHA: binaryBase, PR_HEAD_SHA: binaryHead, SESSION_KEY: "repo:1:pr:binary:v2", - MAX_DIFF_BYTES: "20000", + MAX_DIFF_BYTES: hardMax, AUTOMATIC_MAX_DIFF_BYTES: "4000", LARGE_DIFF_APPROVED: String(approved), + PRIVATE_REPOSITORY: String(privateRepo), CHUNK_TARGET_BYTES: "600", READINESS_CONTEXT_SHA256: "binary-context", }, @@ -549,6 +552,10 @@ try { assert.notEqual(blockedBinary.status, 0); assert.match(blockedBinary.stderr, /automatic reviews are limited to 4000 bytes/); assert.equal(prepareBinary(true).status, 0); + assert.equal(prepareBinary(false, true).status, 0); + const blockedPrivateHardCap = prepareBinary(false, true, "4000"); + assert.notEqual(blockedPrivateHardCap.status, 0); + assert.match(blockedPrivateHardCap.stderr, /configured total limit is 4000 bytes/); const repo = path.join(temporary, "repo"); fs.mkdirSync(repo); @@ -590,12 +597,13 @@ try { MAX_DIFF_BYTES: "1000000", AUTOMATIC_MAX_DIFF_BYTES: "1000000", LARGE_DIFF_APPROVED: "false", + PRIVATE_REPOSITORY: "false", CHUNK_TARGET_BYTES: "600", READINESS_CONTEXT_SHA256: "context-v1", }, }); assert.equal(result.status, 0, result.stderr); - const prepareWithPolicy = (automaticMax, approved, hardMax = "1000000") => + const prepareWithPolicy = (automaticMax, approved, hardMax = "1000000", privateRepo = false) => spawnSync(process.execPath, [ path.join(path.dirname(new URL(import.meta.url).pathname), "prepare.mjs"), ], { @@ -611,6 +619,7 @@ try { MAX_DIFF_BYTES: hardMax, AUTOMATIC_MAX_DIFF_BYTES: automaticMax, LARGE_DIFF_APPROVED: String(approved), + PRIVATE_REPOSITORY: String(privateRepo), CHUNK_TARGET_BYTES: "600", READINESS_CONTEXT_SHA256: "context-v1", }, @@ -619,6 +628,7 @@ try { assert.notEqual(blockedAutomatic.status, 0); assert.match(blockedAutomatic.stderr, /automatic reviews are limited to 1000 bytes/); assert.equal(prepareWithPolicy("1000", true).status, 0); + assert.equal(prepareWithPolicy("1000", false, "1000000", true).status, 0); const blockedHardCap = prepareWithPolicy("1000", true, "1000"); assert.notEqual(blockedHardCap.status, 0); assert.match(blockedHardCap.stderr, /configured total limit is 1000 bytes/); @@ -660,6 +670,7 @@ try { MAX_DIFF_BYTES: "1000000", AUTOMATIC_MAX_DIFF_BYTES: "1000000", LARGE_DIFF_APPROVED: "false", + PRIVATE_REPOSITORY: "false", CHUNK_TARGET_BYTES: "600", READINESS_CONTEXT_SHA256: "context-v1", }, @@ -697,6 +708,7 @@ try { MAX_DIFF_BYTES: "1000000", AUTOMATIC_MAX_DIFF_BYTES: "1000000", LARGE_DIFF_APPROVED: "false", + PRIVATE_REPOSITORY: "false", CHUNK_TARGET_BYTES: "600", READINESS_CONTEXT_SHA256: "context-v1", }, @@ -757,8 +769,9 @@ try { PR_HEAD_SHA: mergedHead, SESSION_KEY: "repo:1:pr:2:v2", MAX_DIFF_BYTES: "1000000", - AUTOMATIC_MAX_DIFF_BYTES: "1000000", - LARGE_DIFF_APPROVED: "false", + AUTOMATIC_MAX_DIFF_BYTES: "1000000", + LARGE_DIFF_APPROVED: "false", + PRIVATE_REPOSITORY: "false", CHUNK_TARGET_BYTES: "600", READINESS_CONTEXT_SHA256: "context-v1", }; @@ -1106,8 +1119,9 @@ fs.writeFileSync(outputFile, JSON.stringify(result)); env: { ...process.env, REPOSITORY_DIR: repo, PR_REVIEW_STATE_DIR: legacyState, PR_BASE_SHA: base, PR_HEAD_SHA: nextHead, SESSION_KEY: "repo:1:pr:2:v2", MAX_DIFF_BYTES: "1000000", - AUTOMATIC_MAX_DIFF_BYTES: "1000000", - LARGE_DIFF_APPROVED: "false", CHUNK_TARGET_BYTES: "600", + AUTOMATIC_MAX_DIFF_BYTES: "1000000", + LARGE_DIFF_APPROVED: "false", + PRIVATE_REPOSITORY: "false", CHUNK_TARGET_BYTES: "600", READINESS_CONTEXT_SHA256: "context-v1", GITHUB_OUTPUT: output }, }); assert.equal(prepared.status, 0, prepared.stderr); @@ -1459,6 +1473,7 @@ fs.writeFileSync(outputFile, JSON.stringify(result)); MAX_DIFF_BYTES: "1000000", AUTOMATIC_MAX_DIFF_BYTES: "1000000", LARGE_DIFF_APPROVED: "false", + PRIVATE_REPOSITORY: "false", CHUNK_TARGET_BYTES: "600", READINESS_CONTEXT_SHA256: "context-v1", }, diff --git a/.github/workflows/codex-openai-review.yml b/.github/workflows/codex-openai-review.yml index da9d584..c470dbb 100644 --- a/.github/workflows/codex-openai-review.yml +++ b/.github/workflows/codex-openai-review.yml @@ -98,6 +98,7 @@ jobs: head_sha: ${{ steps.pr.outputs.head_sha }} request_comment_id: ${{ steps.pr.outputs.request_comment_id }} large_diff_approved: ${{ steps.pr.outputs.large_diff_approved }} + private_repository: ${{ steps.pr.outputs.private_repository }} steps: - name: Check out the exact comment-trigger implementation if: github.event_name == 'issue_comment' @@ -193,6 +194,7 @@ jobs: } core.setOutput('eligible', String(eligible)); core.setOutput('large_diff_approved', String(largeDiffApproved)); + core.setOutput('private_repository', String(pr.base.repo?.private === true)); core.setOutput('number', String(pr.number)); core.setOutput('base_sha', pr.base.sha); core.setOutput('base_ref', pr.base.ref); @@ -881,6 +883,7 @@ jobs: MAX_DIFF_BYTES: ${{ env.REVIEW_MAX_DIFF_BYTES }} AUTOMATIC_MAX_DIFF_BYTES: ${{ env.REVIEW_AUTOMATIC_MAX_DIFF_BYTES }} LARGE_DIFF_APPROVED: ${{ needs.resolve.outputs.large_diff_approved }} + PRIVATE_REPOSITORY: ${{ needs.resolve.outputs.private_repository }} CHUNK_TARGET_BYTES: ${{ inputs.chunk-target-bytes }} READINESS_CONTEXT_SHA256: ${{ steps.prepare_readiness.outputs.context_sha256 }} run: node "$REVIEWER_SOURCE_DIR/.github/scripts/pr-review/prepare.mjs" diff --git a/README.md b/README.md index 20212d9..d579748 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,12 @@ and `actions: write` so the reviewer can restore the latest per-PR Codex session artifact and delete superseded snapshots only after a replacement upload succeeds. -Complete binary-aware Git patches up to 5,000,000 bytes are reviewed -automatically. Binary models, images, and other changed binary payloads count -toward this limit even though their contents are not sent to the model. For a larger -diff, an admin of the **calling repository** must post this line as a PR comment -with the current full head commit SHA: +In public caller repositories, complete binary-aware Git patches up to +5,000,000 bytes are reviewed automatically. Binary models, images, and other +changed binary payloads count toward this limit even though their contents are +not sent to the model. For a larger public-repository diff, an admin of the +**calling repository** must post this line as a PR comment with the current +full head commit SHA: ```text @codex review approve <40-character-head-sha> @@ -45,8 +46,11 @@ with the current full head commit SHA: The shared reviewer checks the comment author's admin permission in the caller repository and the live head SHA before model work. A push requires a new approval. This uses the existing `issue_comment` trigger and works for public -and private callers without a GitHub Environment or per-repository approver -configuration. Diffs above 100,000,000 bytes remain rejected. +callers without a GitHub Environment or per-repository approver configuration. +Private caller repositories skip the 5,000,000-byte approval gate and review +automatically. The caller's base repository privacy comes from GitHub's PR API; +unknown privacy is treated as public. Diffs above 100,000,000 bytes remain +rejected in every repository. ## Behavior @@ -155,7 +159,7 @@ configuration. Diffs above 100,000,000 bytes remain rejected. `pull_request_target` workflow and use the caller's explicitly forwarded secret. Secrets from the contributor's fork are not imported or used. - Automatic PR events permit an external contributor to consume review - requests up to the 5,000,000-byte automatic limit. Use a + requests up to the 5,000,000-byte automatic limit in public callers. Use a dedicated API project with appropriate usage limits and restrict the organization secret to selected repositories. - Complete diffs larger than 100,000,000 bytes fail before Codex runs. The