diff --git a/.github/workflows/review-gate-reconcile.yml b/.github/workflows/review-gate-reconcile.yml new file mode 100644 index 0000000..e0cb087 --- /dev/null +++ b/.github/workflows/review-gate-reconcile.yml @@ -0,0 +1,43 @@ +name: Reconcile review findings gate + +on: + schedule: + - cron: "*/15 * * * *" + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +jobs: + scheduled-review-gate: + name: Reconcile Review gate checks + runs-on: ubuntu-latest + timeout-minutes: 14 + permissions: + contents: read + pull-requests: read + checks: write + + steps: + - name: Checkout trusted default-branch code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 1 + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 22.13.0 + + - name: Reconcile open pull request checks + env: + GH_TOKEN: ${{ github.token }} + run: >- + node scripts/publish-review-gate-check.mjs + --repo "${{ github.repository }}" + --reconcile-open-prs + --max-prs 4 + --details-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.github/workflows/review-gate.yml b/.github/workflows/review-gate.yml index db6bacd..de4bd8f 100644 --- a/.github/workflows/review-gate.yml +++ b/.github/workflows/review-gate.yml @@ -1,11 +1,6 @@ name: Review findings gate on: - workflow_dispatch: - inputs: - pr: - description: Pull request number to check - required: true pull_request: types: [opened, reopened, synchronize, ready_for_review, edited] pull_request_review: @@ -20,6 +15,10 @@ permissions: jobs: review-gate: name: Review gate + if: >- + github.event_name == 'pull_request' || + github.event_name == 'pull_request_review' || + github.event_name == 'pull_request_review_comment' runs-on: ubuntu-latest timeout-minutes: 8 @@ -28,9 +27,7 @@ jobs: id: resolve-pr shell: bash env: - GH_TOKEN: ${{ github.token }} - EVENT_NAME: ${{ github.event_name }} - PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr }} + PR_NUMBER: ${{ github.event.pull_request.number }} EVENT_HEAD_REF: ${{ github.event.pull_request.head.ref }} EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }} EVENT_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} @@ -43,22 +40,10 @@ jobs: exit 1 fi - if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then - mapfile -t pr_metadata < <( - gh pr view "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" \ - --json headRefName,baseRefName,headRepository,headRefOid \ - --jq '.headRefName, .baseRefName, .headRepository.nameWithOwner, .headRefOid' - ) - head_ref="${pr_metadata[0]:-}" - base_ref="${pr_metadata[1]:-}" - head_repo="${pr_metadata[2]:-}" - head_sha="${pr_metadata[3]:-}" - else - head_ref="${EVENT_HEAD_REF}" - base_ref="${EVENT_BASE_REF}" - head_repo="${EVENT_HEAD_REPO}" - head_sha="${EVENT_HEAD_SHA}" - fi + head_ref="${EVENT_HEAD_REF}" + base_ref="${EVENT_BASE_REF}" + head_repo="${EVENT_HEAD_REPO}" + head_sha="${EVENT_HEAD_SHA}" if [ -z "${head_ref}" ] || [ -z "${base_ref}" ] || [ -z "${head_repo}" ] || [ -z "${head_sha}" ]; then echo "Could not resolve complete pull request metadata for #${PR_NUMBER}." >&2 diff --git a/scripts/check-pr-review-gate.mjs b/scripts/check-pr-review-gate.mjs index 09b2414..d60c9ed 100644 --- a/scripts/check-pr-review-gate.mjs +++ b/scripts/check-pr-review-gate.mjs @@ -1,7 +1,19 @@ #!/usr/bin/env node -import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; +import { + DEFAULT_GH_RETRY_ATTEMPTS, + DEFAULT_GH_RETRY_BACKOFF_MS, + runGhText, +} from "./lib/github-cli-retry.mjs"; + +const BLOCKING_STATE_EXIT_CODE = 1; +const EVALUATION_FAILURE_EXIT_CODE = 2; +const DEFAULT_PR_FINDING_AUTHOR = "chatgpt-codex-connector"; +const RESOLVED_MINIMIZED_REASON = "resolved"; +const ALLOWED_MISSING_HEAD_REVIEW_MARKER = "review-gate:allowed-missing-head-review"; +const CODEX_SEVERITY_BADGE_PATTERN = + /!\[P[0-3] Badge\]\(https:\/\/img\.shields\.io\/badge\/P[0-3]-[^)\s]+\)/u; const rawArgs = process.argv.slice(2); const args = new Set(rawArgs); @@ -9,25 +21,29 @@ const strictHeadReview = args.has("--strict-head-review"); const allowMissingHeadReview = args.has("--allow-missing-head-review"); const waitHeadReviewMs = readNonNegativeIntegerArg("--wait-head-review-ms", 0); const pollIntervalMs = readNonNegativeIntegerArg("--poll-interval-ms", 10_000); +const retryAttempts = readPositiveIntegerArg("--retry-attempts", DEFAULT_GH_RETRY_ATTEMPTS); +const retryBackoffMs = readNonNegativeIntegerArg("--retry-backoff-ms", DEFAULT_GH_RETRY_BACKOFF_MS); const fixturePaths = readFixturePaths(); const requiredReviewAuthor = readArgValue("--required-review-author"); +const prFindingAuthor = requiredReviewAuthor ?? DEFAULT_PR_FINDING_AUTHOR; const expectedHeadOid = readArgValue("--expected-head-oid"); const explicitRepo = readArgValue("--repo"); const explicitPr = readArgValue("--pr"); let fixtureIndex = 0; -const ALLOWED_MISSING_HEAD_REVIEW_MARKER = "review-gate:allowed-missing-head-review"; const repo = explicitRepo ?? runText(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]); const prNumber = Number(explicitPr ?? runText(["pr", "view", "--json", "number", "-q", ".number"])); -if (!repo || !repo.includes("/")) fail("Could not determine GitHub repo. Pass --repo owner/name."); +if (!repo || !repo.includes("/")) + failEvaluation("Could not determine GitHub repo. Pass --repo owner/name."); if (!Number.isInteger(prNumber) || prNumber < 1) - fail("Could not determine PR number. Pass --pr ."); + failEvaluation("Could not determine PR number. Pass --pr ."); -const { pr, unresolvedThreads, blockingReviews, headReviews } = await fetchEvaluatedPr(); +const { pr, unresolvedThreads, blockingReviews, blockingComments, headReviews } = + await fetchEvaluatedPr(); const bodyIssues = evaluatePullRequestBody(pr); -reportBlockingState({ unresolvedThreads, blockingReviews }); +reportBlockingState({ unresolvedThreads, blockingReviews, blockingComments }); const missingHeadReview = strictHeadReview && headReviews.length === 0; if (missingHeadReview) { @@ -43,6 +59,7 @@ if (missingHeadReview) { if ( unresolvedThreads.length > 0 || blockingReviews.length > 0 || + blockingComments.length > 0 || bodyIssues.length > 0 || (missingHeadReview && !allowMissingHeadReview) ) { @@ -50,7 +67,7 @@ if ( console.error(`PR body workflow/template issues on ${repo}#${prNumber}:`); for (const issue of bodyIssues) console.error(`- ${issue}`); } - process.exit(1); + process.exit(BLOCKING_STATE_EXIT_CODE); } const latestReview = pr.reviews.nodes.at(-1); @@ -67,10 +84,10 @@ async function fetchEvaluatedPr() { while (true) { const result = fetchReviewGraph(); - const pr = result.data?.repository?.pullRequest; - if (!pr) fail(`Could not fetch PR #${prNumber} from ${repo}.`); + const pr = result?.data?.repository?.pullRequest; + if (!pr) failEvaluation(`Could not fetch PR #${prNumber} from ${repo}.`); if (expectedHeadOid && pr.headRefOid !== expectedHeadOid) { - fail( + failEvaluation( `PR head changed while evaluating ${repo}#${prNumber}: expected ${expectedHeadOid}, found ${pr.headRefOid}.`, ); } @@ -93,6 +110,12 @@ function evaluatePullRequestReviewState(pr) { const unresolvedThreads = pr.reviewThreads.nodes.filter( (thread) => !thread.isResolved && !thread.isOutdated, ); + const blockingComments = pr.comments.nodes.filter( + (comment) => + (!comment.isMinimized || comment.minimizedReason !== RESOLVED_MINIMIZED_REASON) && + normaliseAuthorLogin(comment.author?.login) === normaliseAuthorLogin(prFindingAuthor) && + CODEX_SEVERITY_BADGE_PATTERN.test(comment.body ?? ""), + ); const authorStates = reduceSubmittedCurrentHeadReviewsByAuthor(pr.reviews.nodes, pr.headRefOid); const blockingReviews = Array.from(authorStates.values()) .map((state) => state.blockingReview) @@ -105,7 +128,7 @@ function evaluatePullRequestReviewState(pr) { !requiredReviewAuthor || normaliseAuthorLogin(review.author?.login) === normaliseAuthorLogin(requiredReviewAuthor), ); - return { unresolvedThreads, blockingReviews, headReviews }; + return { unresolvedThreads, blockingReviews, blockingComments, headReviews }; } function reduceSubmittedCurrentHeadReviewsByAuthor(reviews, headRefOid) { @@ -206,7 +229,7 @@ function evaluatePullRequestBody(pr) { return issues; } -function reportBlockingState({ unresolvedThreads, blockingReviews }) { +function reportBlockingState({ unresolvedThreads, blockingReviews, blockingComments }) { if (unresolvedThreads.length > 0) { console.error(`Unresolved review threads on ${repo}#${prNumber}:`); for (const thread of unresolvedThreads) { @@ -216,6 +239,17 @@ function reportBlockingState({ unresolvedThreads, blockingReviews }) { } } + if (blockingComments.length > 0) { + console.error(`Unresolved PR-level review findings on ${repo}#${prNumber}:`); + for (const comment of blockingComments) { + console.error(`- ${comment.url ?? comment.id}`); + console.error(` author: ${comment.author?.login ?? "unknown"}`); + } + console.error( + "Minimize each finding with GitHub Hide → Resolved after dispositioning it. The next scheduled Review gate run will clear the check; the change is not immediate.", + ); + } + if (blockingReviews.length > 0) { console.error(`Requested-changes reviews on ${repo}#${prNumber}:`); for (const review of blockingReviews) { @@ -233,24 +267,38 @@ function fetchReviewGraph() { function fetchPaginatedReviewGraph() { const merged = fetchReviewGraphPage(); - const mergedPr = merged.data?.repository?.pullRequest; + const mergedPr = merged?.data?.repository?.pullRequest; if (!mergedPr) return merged; + ensureReviewConnections(mergedPr); let reviewThreadsPageInfo = mergedPr.reviewThreads.pageInfo; while (reviewThreadsPageInfo?.hasNextPage) { const page = fetchReviewThreadsGraphPage(reviewThreadsPageInfo.endCursor); - const pr = page.data?.repository?.pullRequest; - if (!pr) break; + const pr = page?.data?.repository?.pullRequest; + if (!pr) failEvaluation(`Could not fetch the next review-thread page for ${repo}#${prNumber}.`); + ensureReviewConnections(pr); mergedPr.reviewThreads.nodes.push(...pr.reviewThreads.nodes); reviewThreadsPageInfo = pr.reviewThreads.pageInfo; mergedPr.reviewThreads.pageInfo = reviewThreadsPageInfo; } + let commentsPageInfo = mergedPr.comments.pageInfo; + while (commentsPageInfo?.hasNextPage) { + const page = fetchCommentsGraphPage(commentsPageInfo.endCursor); + const pr = page?.data?.repository?.pullRequest; + if (!pr) failEvaluation(`Could not fetch the next PR-comment page for ${repo}#${prNumber}.`); + ensureReviewConnections(pr); + mergedPr.comments.nodes.push(...pr.comments.nodes); + commentsPageInfo = pr.comments.pageInfo; + mergedPr.comments.pageInfo = commentsPageInfo; + } + let reviewsPageInfo = mergedPr.reviews.pageInfo; while (reviewsPageInfo?.hasNextPage) { const page = fetchReviewsGraphPage(reviewsPageInfo.endCursor); - const pr = page.data?.repository?.pullRequest; - if (!pr) break; + const pr = page?.data?.repository?.pullRequest; + if (!pr) failEvaluation(`Could not fetch the next review page for ${repo}#${prNumber}.`); + ensureReviewConnections(pr); mergedPr.reviews.nodes.push(...pr.reviews.nodes); reviewsPageInfo = pr.reviews.pageInfo; mergedPr.reviews.pageInfo = reviewsPageInfo; @@ -271,11 +319,29 @@ function fetchReviewGraphPage() { "-F", `number=${prNumber}`, "-f", - "query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){body headRefName baseRefName headRepository{nameWithOwner} headRefOid reviewThreads(first:100){pageInfo{hasNextPage endCursor} nodes{id isResolved isOutdated path line comments(first:1){nodes{url author{login} body}}}} reviews(first:100){pageInfo{hasNextPage endCursor} nodes{state submittedAt url author{login} commit{oid}}}}}}", + "query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){body headRefName baseRefName headRepository{nameWithOwner} headRefOid comments(first:100){pageInfo{hasNextPage endCursor} nodes{id url createdAt isMinimized minimizedReason author{login} body}} reviewThreads(first:100){pageInfo{hasNextPage endCursor} nodes{id isResolved isOutdated path line comments(first:1){nodes{url author{login} body}}}} reviews(first:100){pageInfo{hasNextPage endCursor} nodes{state submittedAt url author{login} commit{oid}}}}}}", ]; return runJson(commandArgs); } +function fetchCommentsGraphPage(after) { + const [owner, name] = repo.split("/"); + return runJson([ + "api", + "graphql", + "-F", + `owner=${owner}`, + "-F", + `name=${name}`, + "-F", + `number=${prNumber}`, + "-F", + `after=${after}`, + "-f", + "query=query($owner:String!,$name:String!,$number:Int!,$after:String!){repository(owner:$owner,name:$name){pullRequest(number:$number){comments(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{id url createdAt isMinimized minimizedReason author{login} body}}}}}", + ]); +} + function fetchReviewThreadsGraphPage(after) { const [owner, name] = repo.split("/"); return runJson([ @@ -332,11 +398,15 @@ function mergeReviewGraphPages(pages) { const merged = JSON.parse(JSON.stringify(firstPage)); const mergedPr = merged.data?.repository?.pullRequest; if (!mergedPr) return merged; + ensureReviewConnections(mergedPr); for (const page of remainingPages) { const pr = page.data?.repository?.pullRequest; if (!pr) continue; + ensureReviewConnections(pr); + mergedPr.comments.nodes.push(...pr.comments.nodes); mergedPr.reviewThreads.nodes.push(...pr.reviewThreads.nodes); mergedPr.reviews.nodes.push(...pr.reviews.nodes); + mergedPr.comments.pageInfo = pr.comments.pageInfo; mergedPr.reviewThreads.pageInfo = pr.reviewThreads.pageInfo; mergedPr.reviews.pageInfo = pr.reviews.pageInfo; } @@ -346,9 +416,20 @@ function mergeReviewGraphPages(pages) { function annotateReviewHeadRef(result) { const pr = result?.data?.repository?.pullRequest; if (!pr) return result; + ensureReviewConnections(pr); + return result; +} + +function ensureReviewConnections(pr) { + pr.comments ??= { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } }; + pr.comments.nodes ??= []; + pr.comments.pageInfo ??= { hasNextPage: false, endCursor: null }; + pr.reviewThreads ??= { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } }; + pr.reviewThreads.nodes ??= []; pr.reviewThreads.pageInfo ??= { hasNextPage: false, endCursor: null }; + pr.reviews ??= { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } }; + pr.reviews.nodes ??= []; pr.reviews.pageInfo ??= { hasNextPage: false, endCursor: null }; - return result; } function readFixturePaths() { @@ -367,7 +448,14 @@ function readNonNegativeIntegerArg(name, defaultValue) { const rawValue = readArgValue(name); if (rawValue === null) return defaultValue; const value = Number(rawValue); - if (!Number.isInteger(value) || value < 0) fail(`${name} must be a non-negative integer.`); + if (!Number.isInteger(value) || value < 0) + failEvaluation(`${name} must be a non-negative integer.`); + return value; +} + +function readPositiveIntegerArg(name, defaultValue) { + const value = readNonNegativeIntegerArg(name, defaultValue); + if (value < 1) failEvaluation(`${name} must be a positive integer.`); return value; } @@ -376,19 +464,33 @@ function normaliseAuthorLogin(login) { } function runText(commandArgs) { - return execFileSync("gh", commandArgs, { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }).trim(); + try { + return runGhText(commandArgs, { + attempts: retryAttempts, + backoffMs: retryBackoffMs, + operation: "evaluation", + }); + } catch (error) { + failEvaluation(formatErrorMessage(error)); + } } function runJson(commandArgs) { - return JSON.parse(runText(commandArgs)); + const output = runText(commandArgs); + try { + return JSON.parse(output); + } catch (error) { + failEvaluation(`GitHub CLI returned malformed JSON: ${formatErrorMessage(error)}`); + } +} + +function formatErrorMessage(error) { + return error instanceof Error ? error.message : String(error); } -function fail(message) { - console.error(message); - process.exit(1); +function failEvaluation(message) { + console.error(`Review gate could not evaluate: ${message}`); + process.exit(EVALUATION_FAILURE_EXIT_CODE); } function sleep(ms) { diff --git a/scripts/lib/github-cli-retry.mjs b/scripts/lib/github-cli-retry.mjs new file mode 100644 index 0000000..4b7b1c7 --- /dev/null +++ b/scripts/lib/github-cli-retry.mjs @@ -0,0 +1,39 @@ +import { execFileSync } from "node:child_process"; +export const DEFAULT_GH_RETRY_ATTEMPTS = 6; +export const DEFAULT_GH_RETRY_BACKOFF_MS = 10_000; +const MAX_GH_RETRY_BACKOFF_MS = 60_000; +const TRANSIENT_GH_FAILURE_PATTERN = + /\b(?:HTTP\s+(?:429|500|502|503|504)|(?:secondary\s+)?rate limit(?:ed|ing)?|ETIMEDOUT|timeout|timed out|context deadline exceeded|deadline exceeded|ECONNRESET|connection reset|ENOTFOUND|EAI_AGAIN|getaddrinfo|could not resolve host|no such host|temporary failure in name resolution)\b/iu; + +export function runGhText(commandArgs, options = {}) { + const { + attempts = DEFAULT_GH_RETRY_ATTEMPTS, + backoffMs = DEFAULT_GH_RETRY_BACKOFF_MS, + operation = "operation", + } = options; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return execFileSync("gh", commandArgs, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch (error) { + const detail = + String(error?.stderr ?? "").trim() || + (error instanceof Error ? error.message : String(error)); + const evidence = [error?.code, error?.signal, detail].filter(Boolean).join("\n"); + if (!TRANSIENT_GH_FAILURE_PATTERN.test(evidence)) { + throw new Error(`GitHub CLI failed without a retryable error: ${detail}`); + } + if (attempt >= attempts) { + throw new Error(`GitHub CLI ${operation} failed after ${attempts} attempts: ${detail}`); + } + const delay = Math.min(backoffMs * 2 ** Math.min(attempt - 1, 20), MAX_GH_RETRY_BACKOFF_MS); + console.warn( + `GitHub CLI transient failure on attempt ${attempt}/${attempts}: ${detail}. Retrying in ${delay}ms.`, + ); + if (delay > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay); + } + } + throw new Error(`GitHub CLI ${operation} ended without a result.`); +} diff --git a/scripts/publish-review-gate-check.mjs b/scripts/publish-review-gate-check.mjs new file mode 100644 index 0000000..0bb471e --- /dev/null +++ b/scripts/publish-review-gate-check.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { fileURLToPath, URL } from "node:url"; +import { + DEFAULT_GH_RETRY_ATTEMPTS, + DEFAULT_GH_RETRY_BACKOFF_MS, + runGhText, +} from "./lib/github-cli-retry.mjs"; + +const CHECK_RUN_NAME = "Review gate (scheduled)"; +const EXIT_VERDICTS = new Map([ + [0, { conclusion: "success", title: "Scheduled review gate passed" }], + [1, { conclusion: "failure", title: "Scheduled review gate found blocking review state" }], + [2, { conclusion: "action_required", title: "Scheduled review gate could not evaluate" }], +]); +const rawArgs = process.argv.slice(2); +const repo = readArg("--repo", true); +const detailsUrl = readArg("--details-url", true); +const retryAttempts = readIntegerArg("--retry-attempts", DEFAULT_GH_RETRY_ATTEMPTS, 1); +const retryBackoffMs = readIntegerArg("--retry-backoff-ms", DEFAULT_GH_RETRY_BACKOFF_MS, 0); +if (!repo.includes("/")) fail("--repo must be owner/name."); +try { + if (rawArgs.includes("--reconcile-open-prs")) { + reconcileOpenPullRequests(); + } else { + publishCheck(readArg("--head-sha", true), Number(readArg("--exit-code", true))); + } +} catch (error) { + const detail = error instanceof Error ? error.message : String(error); + fail(`Review gate publication could not complete: ${detail}`, 2); +} +function reconcileOpenPullRequests() { + const maxPrs = readIntegerArg("--max-prs", 25, 1); + const selectionOffset = readIntegerArg( + "--selection-offset", + Math.floor(Date.now() / (15 * 60 * 1000)), + 0, + ); + const pages = JSON.parse( + runGithub( + ["api", "--paginate", "--slurp", `repos/${repo}/pulls?state=open&per_page=100`], + "pull request discovery", + ), + ); + const pulls = (Array.isArray(pages?.[0]) ? pages.flat() : pages).filter(Boolean); + const eligible = []; + + for (const pr of pulls) { + const label = `#${pr.number ?? "unknown"}`; + const reason = + String(pr.state).toLowerCase() !== "open" + ? "pull request is not open" + : pr.draft + ? "pull request is a draft" + : pr.head?.repo?.full_name !== repo + ? `fork head ${pr.head?.repo?.full_name ?? "unknown"} cannot receive a trusted scheduled Review gate check` + : !Number.isInteger(pr.number) || !/^[0-9a-f]{40}$/iu.test(pr.head?.sha ?? "") + ? "pull request metadata is incomplete" + : null; + if (reason) console.log(`Skipping ${label}: ${reason}.`); + else eligible.push(pr); + } + + const start = eligible.length === 0 ? 0 : (selectionOffset * maxPrs) % eligible.length; + const selected = [...eligible.slice(start), ...eligible.slice(0, start)].slice(0, maxPrs); + if (eligible.length > maxPrs) { + console.warn( + `Review gate schedule cap hit: processing ${maxPrs} of ${eligible.length} eligible pull requests.`, + ); + } + + for (const pr of selected) { + publishCheck(pr.head.sha, evaluatePullRequest(pr)); + } + + console.log(`Scheduled Review gate evaluated ${selected.length} pull request(s).`); +} + +function evaluatePullRequest(pr) { + const evaluator = fileURLToPath(new URL("./check-pr-review-gate.mjs", import.meta.url)); + const result = spawnSync( + process.execPath, + [ + evaluator, + "--repo", + repo, + "--pr", + String(pr.number), + "--strict-head-review", + "--required-review-author", + "chatgpt-codex-connector", + "--wait-head-review-ms", + "180000", + "--poll-interval-ms", + "10000", + "--allow-missing-head-review", + "--expected-head-oid", + pr.head.sha, + ], + { encoding: "utf8", env: process.env }, + ); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + return [0, 1, 2].includes(result.status) ? result.status : 2; +} + +function publishCheck(headSha, exitCode) { + const verdict = EXIT_VERDICTS.get(exitCode); + if (!/^[0-9a-f]{40}$/iu.test(headSha)) fail("--head-sha must be a full commit SHA."); + if (!verdict) fail("--exit-code must be 0, 1, or 2."); + const summary = + exitCode === 0 + ? "The review gate evaluated the pull request head and found no blocking state." + : exitCode === 1 + ? "The review gate evaluated the pull request head and found a blocking state." + : "The review gate could not evaluate the complete pull request review state."; + const fields = { + name: CHECK_RUN_NAME, + head_sha: headSha, + status: "completed", + conclusion: verdict.conclusion, + details_url: detailsUrl, + "output[title]": verdict.title, + "output[summary]": summary, + }; + const formArgs = Object.entries(fields).flatMap(([name, value]) => ["-f", `${name}=${value}`]); + runGithub( + [ + "api", + "-X", + "POST", + `repos/${repo}/check-runs`, + "-H", + "X-GitHub-Api-Version: 2022-11-28", + ...formArgs, + ], + "check publication", + ); + console.log(`${CHECK_RUN_NAME} ${verdict.conclusion} check created for ${headSha}.`); +} + +function runGithub(commandArgs, operation) { + return runGhText(commandArgs, { + attempts: retryAttempts, + backoffMs: retryBackoffMs, + operation, + }); +} + +function readArg(name, required = false) { + const index = rawArgs.indexOf(name); + const value = index >= 0 ? rawArgs[index + 1] : null; + if (required && (!value || value.startsWith("--"))) fail(`Pass ${name} .`); + return value; +} + +function readIntegerArg(name, defaultValue, minimum) { + const rawValue = readArg(name); + if (rawValue === null) return defaultValue; + const value = Number(rawValue); + if (!Number.isInteger(value) || value < minimum) fail(`${name} must be at least ${minimum}.`); + return value; +} + +function fail(message, exitCode = 1) { + console.error(message); + process.exit(exitCode); +} diff --git a/tests/extension/ci-workflow.test.ts b/tests/extension/ci-workflow.test.ts index c262c8b..a201ff9 100644 --- a/tests/extension/ci-workflow.test.ts +++ b/tests/extension/ci-workflow.test.ts @@ -37,37 +37,68 @@ describe("Pack CI workflow", () => { expect(workflow).not.toContain("actions/upload-artifact"); }); - it("runs a read-only current-head review findings gate", async () => { - const workflow = await readFile( + it("isolates PR-head execution from trusted scheduled reconciliation", async () => { + const prWorkflow = await readFile( path.join(rootDir, ".github", "workflows", "review-gate.yml"), "utf8", ); + const trustedWorkflow = await readFile( + path.join(rootDir, ".github", "workflows", "review-gate-reconcile.yml"), + "utf8", + ); - expect(workflow).toContain("workflow_dispatch:"); - expect(workflow).toContain("pull_request:"); - expect(workflow).toContain("pull_request_review:"); - expect(workflow).toContain("pull_request_review_comment:"); - expect(workflow).not.toContain("pull_request_target:"); - expect(workflow).not.toContain("schedule:"); - expect(workflow).not.toContain("issue_comment:"); - expect(workflow).not.toContain("github.event.issue"); - expect(workflow).not.toContain("/review-gate"); - expect(workflow).toContain("name: Review findings gate"); - expect(workflow).toContain("name: Review gate"); - expect(workflow).toContain("pull-requests: read"); - expect(workflow).not.toContain("statuses: write"); - expect(workflow).toContain("GH_TOKEN: ${{ github.token }}"); - expect(workflow).toContain("repository: ${{ steps.resolve-pr.outputs.head_repo }}"); - expect(workflow).toContain("ref: ${{ steps.resolve-pr.outputs.head_sha }}"); - expect(workflow).toContain("pnpm install --frozen-lockfile"); - expect(workflow).toContain("pnpm workflow:preflight"); - expect(workflow).toContain("pnpm review:gate"); - expect(workflow).toContain("ready_for_review, edited"); - expect(workflow).toContain("--strict-head-review"); - expect(workflow).toContain("--required-review-author chatgpt-codex-connector"); - expect(workflow).toContain("--wait-head-review-ms 180000"); - expect(workflow).toContain("--allow-missing-head-review"); - expect(workflow).toContain('--expected-head-oid "${{ steps.resolve-pr.outputs.head_sha }}"'); + expect(prWorkflow).not.toContain("workflow_dispatch:"); + expect(prWorkflow).not.toContain("schedule:"); + expect(prWorkflow).toContain( + "pull_request:\n types: [opened, reopened, synchronize, ready_for_review, edited]", + ); + expect(prWorkflow).toContain("pull_request_review:\n types: [submitted, edited, dismissed]"); + expect(prWorkflow).toContain( + "pull_request_review_comment:\n types: [created, edited, deleted]", + ); + expect(prWorkflow).not.toContain("pull_request_target:"); + expect(prWorkflow).not.toContain("issue_comment:"); + expect(prWorkflow).not.toContain("github.event.issue"); + expect(prWorkflow).not.toContain("/review-gate"); + expect(prWorkflow).toContain("name: Review gate"); + expect(prWorkflow).not.toContain("checks: write"); + expect(prWorkflow).not.toContain("statuses: write"); + expect(prWorkflow).toContain("GH_TOKEN: ${{ github.token }}"); + expect(prWorkflow).toContain("repository: ${{ steps.resolve-pr.outputs.head_repo }}"); + expect(prWorkflow).toContain("ref: ${{ steps.resolve-pr.outputs.head_sha }}"); + expect(prWorkflow).toContain("pnpm install --frozen-lockfile"); + expect(prWorkflow).toContain("pnpm workflow:preflight"); + expect(prWorkflow).toContain("pnpm review:gate"); + expect(prWorkflow).toContain("ready_for_review, edited"); + expect(prWorkflow).toContain("--strict-head-review"); + expect(prWorkflow).toContain("--required-review-author chatgpt-codex-connector"); + expect(prWorkflow).toContain("--wait-head-review-ms 180000"); + expect(prWorkflow).toContain("--allow-missing-head-review"); + expect(prWorkflow).toContain('--expected-head-oid "${{ steps.resolve-pr.outputs.head_sha }}"'); + + expect(trustedWorkflow).toContain("workflow_dispatch:"); + expect(trustedWorkflow).toContain('schedule:\n - cron: "*/15 * * * *"'); + expect(trustedWorkflow).not.toContain("pull_request:"); + expect(trustedWorkflow).not.toContain("pull_request_review:"); + expect(trustedWorkflow).not.toContain("pull_request_review_comment:"); + expect(trustedWorkflow).not.toContain("github.event.inputs.pr"); + expect(trustedWorkflow).not.toContain("EVENT_NAME: ${{ github.event_name }}"); + expect(trustedWorkflow.match(/checks: write/g)).toHaveLength(1); + expect(trustedWorkflow).toMatch( + /scheduled-review-gate:[\s\S]*?permissions:\n\s+contents: read\n\s+pull-requests: read\n\s+checks: write/, + ); + expect(trustedWorkflow).not.toContain("statuses: write"); + expect(trustedWorkflow).toContain("GH_TOKEN: ${{ github.token }}"); + expect(trustedWorkflow).not.toContain("steps.resolve-pr.outputs"); + expect(trustedWorkflow).not.toContain("cache:"); + expect(trustedWorkflow).not.toContain("pnpm install"); + expect(trustedWorkflow).not.toContain("pnpm workflow:preflight"); + expect(trustedWorkflow).toContain("scripts/publish-review-gate-check.mjs"); + expect(trustedWorkflow).toContain("--reconcile-open-prs"); + expect(trustedWorkflow).toContain("--max-prs 4"); + expect(trustedWorkflow).toMatch( + /scheduled-review-gate:[\s\S]*?ref: \$\{\{ github\.event\.repository\.default_branch \}\}/, + ); }); it("keeps every workflow action reference within the repository selected-actions policy", async () => { diff --git a/tests/scripts/check-pr-review-gate.test.ts b/tests/scripts/check-pr-review-gate.test.ts index 8f5fbf6..54055e9 100644 --- a/tests/scripts/check-pr-review-gate.test.ts +++ b/tests/scripts/check-pr-review-gate.test.ts @@ -1,5 +1,5 @@ -import { execFileSync } from "node:child_process"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -8,6 +8,147 @@ const rootDir = process.cwd(); const scriptPath = path.join(rootDir, "scripts", "check-pr-review-gate.mjs"); describe("PR review gate", () => { + it("fetches the minimization reason on initial and paginated PR comments", () => { + const script = readFileSync(scriptPath, "utf8"); + + expect(script.match(/isMinimized minimizedReason/g)).toHaveLength(2); + }); + + it.each([ + ["unresolved finding", [prFindingComment()], 1], + ["resolved finding", [prFindingComment({ isMinimized: true, minimizedReason: "resolved" })], 0], + [ + "off-topic minimization", + [prFindingComment({ isMinimized: true, minimizedReason: "off-topic" })], + 1, + ], + [ + "outdated minimization", + [prFindingComment({ isMinimized: true, minimizedReason: "outdated" })], + 1, + ], + ["unrelated author", [prFindingComment({ author: "external-reviewer" })], 0], + [ + "non-finding notes", + [ + prFindingComment({ body: "Codex Review: Didn't find any major issues. Breezy!" }), + prFindingComment({ body: "To use Codex here, create an environment for this repo." }), + ], + 0, + ], + ])("maps PR-level %s", (name, comments, expectedExit) => { + const result = runGateFixture(name, comments); + expect(result.status).toBe(expectedExit); + expect(expectedExit === 0 ? result.stdout : result.stderr).toContain( + expectedExit === 0 ? "PR review gate passed" : "Unresolved PR-level review findings", + ); + if (name === "unresolved finding") { + expect(result.stderr).toContain("Hide → Resolved"); + expect(result.stderr).toContain("next scheduled Review gate run"); + } + }); + + it("evaluates PR-level findings from paginated fixture pages", () => { + const fixturePath = writeFixture("paginated-pr-level-findings", { + pages: [ + reviewFixture({ + headRefOid: "head-sha", + comments: [], + commentsPageInfo: { hasNextPage: true, endCursor: "comments-page-1" }, + reviews: [review({ state: "COMMENTED", commit: "head-sha" })], + }), + reviewFixture({ + headRefOid: "head-sha", + comments: [prFindingComment()], + reviews: [], + }), + ], + }); + + const result = spawnSync( + process.execPath, + [scriptPath, "--repo", "lamemustafa/pack", "--pr", "14", "--fixture", fixturePath], + { cwd: rootDir, encoding: "utf8" }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Unresolved PR-level review findings"); + }); + + it("fails as could-not-evaluate when a live pagination page has no PR data", () => { + const firstPage = reviewFixture({ + headRefOid: "head-sha", + commentsPageInfo: { hasNextPage: true, endCursor: "comments-page-1" }, + reviews: [review({ state: "COMMENTED", commit: "head-sha" })], + }); + const missingPrPage = { data: { repository: { pullRequest: null } } }; + const { result, attempts } = runGateWithFakeGh([ + { status: 0, stdout: JSON.stringify(firstPage) }, + { status: 0, stdout: JSON.stringify(missingPrPage) }, + ]); + + expect(result.status).toBe(2); + expect(result.stderr).toContain("Review gate could not evaluate"); + expect(result.stderr).toContain("next PR-comment page"); + expect(attempts).toBe(2); + }); + + it.each([ + ["http-429", "gh: HTTP 429: too many requests\n"], + ["http-500", "gh: HTTP 500: internal server error\n"], + ["http-502", "gh: HTTP 502: bad gateway\n"], + ["http-504", "gh: HTTP 504: gateway timeout\n"], + ["rate-limit", "gh: API rate limit exceeded\n"], + ["timeout", "gh: request timed out\n"], + ["connection-reset", "gh: read: connection reset by peer\n"], + ["dns", "gh: dial tcp: lookup api.github.com: no such host\n"], + ])("retries the allowed %s transient failure", (_name, stderr) => { + const fixture = reviewFixture({ + headRefOid: "head-sha", + reviews: [review({ state: "COMMENTED", commit: "head-sha" })], + }); + const { result, attempts } = runGateWithFakeGh([ + { status: 1, stderr }, + { status: 0, stdout: JSON.stringify(fixture) }, + ]); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("Retrying in 0ms"); + expect(attempts).toBe(2); + }); + + it.each([ + [ + "transient retry exhaustion", + [ + { status: 1, stderr: "gh: Service Unavailable (HTTP 503)\n" }, + { status: 1, stderr: "gh: Service Unavailable (HTTP 503)\n" }, + { status: 1, stderr: "gh: Service Unavailable (HTTP 503)\n" }, + ], + 3, + "failed after 3 attempts", + 3, + ], + [ + "non-transient failure", + [ + { + status: 1, + stderr: "gh: GraphQL: Could not resolve to a Repository with the name pack\n", + }, + ], + 3, + "Could not resolve to a Repository", + 1, + ], + ])("fails closed for %s", (_name, responses, retryAttempts, message, expectedAttempts) => { + const { result, attempts } = runGateWithFakeGh(responses, retryAttempts); + expect(result.status).toBe(2); + expect(result.stderr).toContain("Review gate could not evaluate"); + expect(result.stderr).toContain(message); + expect(attempts).toBe(expectedAttempts); + }); + it("fails when unresolved review threads are present", () => { const fixturePath = writeFixture( "unresolved-thread", @@ -865,6 +1006,8 @@ function reviewFixture({ baseRefName = "master", headRepository = { nameWithOwner: "lamemustafa/pack" }, body = packPrBody(), + comments = [], + commentsPageInfo = { hasNextPage: false, endCursor: null }, reviewThreads = [], reviewThreadsPageInfo = { hasNextPage: false, endCursor: null }, reviews, @@ -875,6 +1018,8 @@ function reviewFixture({ baseRefName?: string; headRepository?: { nameWithOwner: string }; body?: string; + comments?: unknown[]; + commentsPageInfo?: { hasNextPage: boolean; endCursor: string | null }; reviewThreads?: unknown[]; reviewThreadsPageInfo?: { hasNextPage: boolean; endCursor: string | null }; reviews: Array>; @@ -889,6 +1034,10 @@ function reviewFixture({ baseRefName, headRepository, headRefOid, + comments: { + nodes: comments, + pageInfo: commentsPageInfo, + }, reviewThreads: { nodes: reviewThreads, pageInfo: reviewThreadsPageInfo }, reviews: { nodes: reviews, pageInfo: reviewsPageInfo }, }, @@ -897,6 +1046,93 @@ function reviewFixture({ }; } +function prFindingComment( + options: { + id?: string; + isMinimized?: boolean; + minimizedReason?: string | null; + author?: string; + body?: string; + } = {}, +) { + const { + id = "comment-1", + isMinimized = false, + minimizedReason = null, + author = "chatgpt-codex-connector[bot]", + body = "![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat) Fix this.", + } = options; + return { + id, + url: `https://github.com/lamemustafa/pack/pull/14#issuecomment-${id}`, + createdAt: "2026-08-17T12:00:00Z", + isMinimized, + minimizedReason, + author: { login: author }, + body, + }; +} + +function runGateFixture(name: string, comments: unknown[]) { + const fixture = writeFixture( + `pr-finding-${name.replaceAll(" ", "-")}`, + reviewFixture({ + headRefOid: "head-sha", + comments, + reviews: [review({ state: "COMMENTED", commit: "head-sha" })], + }), + ); + return spawnSync( + process.execPath, + [scriptPath, "--repo", "lamemustafa/pack", "--pr", "14", "--fixture", fixture], + { cwd: rootDir, encoding: "utf8" }, + ); +} + +function runGateWithFakeGh( + responses: Array<{ status: number; stdout?: string; stderr?: string }>, + retryAttempts = responses.length, +) { + const directory = mkdtempSync(path.join(tmpdir(), "pack-review-gate-gh-")); + const statePath = path.join(directory, "attempts.txt"); + const fakeGhPath = path.join(directory, "gh"); + const fakeGhSource = `#!/usr/bin/env node +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +const responses = ${JSON.stringify(responses)}; +const statePath = ${JSON.stringify(statePath)}; +const attempt = existsSync(statePath) ? Number(readFileSync(statePath, "utf8")) : 0; +writeFileSync(statePath, String(attempt + 1), "utf8"); +const response = responses[Math.min(attempt, responses.length - 1)]; +if (response.stdout) process.stdout.write(response.stdout); +if (response.stderr) process.stderr.write(response.stderr); +process.exit(response.status); +`; + writeFileSync(fakeGhPath, fakeGhSource, "utf8"); + chmodSync(fakeGhPath, 0o755); + + const result = spawnSync( + process.execPath, + [ + scriptPath, + "--repo", + "lamemustafa/pack", + "--pr", + "14", + "--retry-backoff-ms", + "0", + "--retry-attempts", + String(retryAttempts), + ], + { + cwd: rootDir, + encoding: "utf8", + env: { ...process.env, PATH: `${directory}${path.delimiter}${process.env.PATH ?? ""}` }, + }, + ); + + return { result, attempts: Number(readFileSync(statePath, "utf8")) }; +} + function packPrBody() { return [ "## Summary", diff --git a/tests/scripts/publish-review-gate-check.test.ts b/tests/scripts/publish-review-gate-check.test.ts new file mode 100644 index 0000000..1f6c331 --- /dev/null +++ b/tests/scripts/publish-review-gate-check.test.ts @@ -0,0 +1,188 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const rootDir = process.cwd(); +const scriptPath = path.join(rootDir, "scripts", "publish-review-gate-check.mjs"); +const headSha = "a".repeat(40); + +describe("PR-head Review gate check publisher", () => { + it.each([ + [0, "success"], + [1, "failure"], + [2, "action_required"], + ])("maps exit %i to the %s scheduled Review gate conclusion", (exitCode, conclusion) => { + const { result, calls } = runPublisher(exitCode, [{ status: 0 }]); + expect(result.status).toBe(0); + const call = calls[0]?.join(" ") ?? ""; + expect(call).toContain("repos/lamemustafa/pack/check-runs"); + expect(call).toContain("name=Review gate (scheduled)"); + expect(call).toContain(`head_sha=${headSha}`); + expect(call).toContain(`conclusion=${conclusion}`); + }); + + it.each([ + ["succeeds after retry", [1, 0], 0], + ["reports exhaustion", [1, 1], 2], + ])("%s for transient check publication", (_name, statuses, expectedExit) => { + const responses = statuses.map((status) => ({ + status, + stderr: status ? "gh: HTTP 503\n" : "", + })); + const { result, calls } = runPublisher(1, responses); + expect(result.status).toBe(expectedExit); + if (expectedExit === 2) expect(result.stderr).toContain("failed after 2 attempts"); + expect(calls).toHaveLength(2); + }); + + it("selects only open non-draft same-repository PRs and logs skips and the cap", () => { + const pulls = [ + pull(1), + pull(2, { draft: true }), + pull(3, { state: "closed" }), + pull(4, { headRepo: "external/pack" }), + pull(5), + ]; + const { result, calls } = runScript( + ["--reconcile-open-prs", "--max-prs", "1", "--selection-offset", "0"], + pulls, + cleanReviewFixture(), + ); + const publications = calls.filter((call) => call.includes("repos/lamemustafa/pack/check-runs")); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Skipping #2: pull request is a draft"); + expect(result.stdout).toContain("Skipping #3: pull request is not open"); + expect(result.stdout).toContain("Skipping #4: fork head external/pack"); + expect(result.stderr).toContain("schedule cap hit: processing 1 of 2 eligible"); + expect(publications).toHaveLength(1); + expect(publications[0]).toContain(`head_sha=${headSha}`); + }); + + it("rotates the capped selection across eligible pull requests", () => { + const { result, calls } = runScript( + ["--reconcile-open-prs", "--max-prs", "1", "--selection-offset", "1"], + [pull(1), pull(2)], + cleanReviewFixture(), + ); + const publication = calls.find((call) => call.includes("repos/lamemustafa/pack/check-runs")); + + expect(result.status).toBe(0); + expect(publication).toContain(`head_sha=${"2".repeat(40)}`); + }); + + it("preserves the event gate's current-head review wait for scheduled evaluation", () => { + const script = readFileSync(scriptPath, "utf8"); + expect(script).toMatch(/"--wait-head-review-ms",\s*"180000"/u); + expect(script).toMatch(/"--poll-interval-ms",\s*"10000"/u); + }); +}); + +function runPublisher(exitCode: number, responses: Array<{ status: number; stderr?: string }>) { + return runScript(["--head-sha", headSha, "--exit-code", String(exitCode)], [], {}, responses); +} + +function runScript( + modeArgs: string[], + pulls: unknown[] = [], + fixture: unknown = {}, + responses: Array<{ status: number; stderr?: string }> = [{ status: 0 }], +) { + const directory = mkdtempSync(path.join(tmpdir(), "pack-review-publisher-")); + const callsPath = path.join(directory, "calls.json"); + const fakeGhPath = path.join(directory, "gh"); + writeFileSync(fakeGhPath, fakeGhSource, "utf8"); + chmodSync(fakeGhPath, 0o755); + const result = spawnSync( + process.execPath, + [ + scriptPath, + "--repo", + "lamemustafa/pack", + "--details-url", + "https://github.com/lamemustafa/pack/actions/runs/1", + ...modeArgs, + "--retry-attempts", + String(responses.length), + "--retry-backoff-ms", + "0", + ], + { + cwd: rootDir, + encoding: "utf8", + env: { + ...process.env, + PATH: `${directory}${path.delimiter}${process.env.PATH ?? ""}`, + FAKE_CALLS: callsPath, + FAKE_FIXTURE: JSON.stringify(fixture), + FAKE_PULLS: JSON.stringify([pulls]), + FAKE_RESPONSES: JSON.stringify(responses), + }, + }, + ); + const calls = readFileSync(callsPath, "utf8"); + return { result, calls: JSON.parse(calls) as string[][] }; +} + +const fakeGhSource = `#!/usr/bin/env node +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +const args = process.argv.slice(2); +const calls = existsSync(process.env.FAKE_CALLS) ? JSON.parse(readFileSync(process.env.FAKE_CALLS, "utf8")) : []; +calls.push(args); writeFileSync(process.env.FAKE_CALLS, JSON.stringify(calls), "utf8"); +const text = args.join(" "); +if (text.includes("pulls?state=open")) process.stdout.write(process.env.FAKE_PULLS); +else if (text.includes("graphql")) { + const fixture = JSON.parse(process.env.FAKE_FIXTURE); + const number = Number(args.find((arg) => arg.startsWith("number="))?.split("=")[1]); + const pull = JSON.parse(process.env.FAKE_PULLS).flat().find((item) => item.number === number); + fixture.data.repository.pullRequest.headRefOid = pull.head.sha; + fixture.data.repository.pullRequest.reviews.nodes[0].commit.oid = pull.head.sha; + process.stdout.write(JSON.stringify(fixture)); +} +else if (text.includes("check-runs")) { + const attempts = calls.filter((call) => call.includes("repos/lamemustafa/pack/check-runs")).length; + const responses = JSON.parse(process.env.FAKE_RESPONSES); + const response = responses[Math.min(attempts - 1, responses.length - 1)]; + if (response.stderr) process.stderr.write(response.stderr); + process.exit(response.status); +} else process.exit(1); +`; + +function pull( + number: number, + { draft = false, state = "open", headRepo = "lamemustafa/pack" } = {}, +) { + const sha = number === 1 ? headSha : String(number).repeat(40); + return { + number, + state, + draft, + head: { sha, repo: { full_name: headRepo } }, + }; +} + +const cleanReviewFixture = () => ({ + data: { + repository: { + pullRequest: { + body: "Pack Workflow Preflight\nPrivacy And Data-Flow Impact\nSensitive Surface Review\nVerification\nPR Review Follow-Up\npnpm workflow:preflight", + headRefName: "tapish-codex/test", + headRepository: { nameWithOwner: "lamemustafa/pack" }, + headRefOid: headSha, + reviews: { + nodes: [ + { + state: "COMMENTED", + submittedAt: "2026-08-18T00:00:00Z", + author: { login: "chatgpt-codex-connector" }, + commit: { oid: headSha }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, +});