diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 6d8232e6ac..e32968bc7a 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -17,9 +17,11 @@ reviews: poem: false auto_review: - enabled: true + enabled: false drafts: false - auto_incremental_review: true + auto_incremental_review: false + labels: + - "coderabbit-review-active" path_filters: - "!**/node_modules/**" diff --git a/.github/workflows/label-pr-review-state.yml b/.github/workflows/label-pr-review-state.yml index 040ad91d00..80c0a01ac5 100644 --- a/.github/workflows/label-pr-review-state.yml +++ b/.github/workflows/label-pr-review-state.yml @@ -2,16 +2,28 @@ name: Label PR review state on: schedule: - - cron: '0 * * * *' # hourly fallback + - cron: "0 * * * *" # hourly fallback workflow_dispatch: - pull_request: - types: [opened, reopened, ready_for_review, synchronize, review_requested] + inputs: + pull_request_number: + description: Pull request number to reconcile + required: true + type: number + # This workflow only reads PR metadata and never checks out or executes PR code. + # pull_request_target gives fork PRs a token that can update labels and comments. + pull_request_target: + types: [opened, reopened, ready_for_review, synchronize, review_requested, labeled, unlabeled] pull_request_review: types: [submitted, dismissed] + workflow_run: + workflows: ["Code QA Roo Code", "E2E Tests (Mocked)", "Webview Visual Regression", "CodeQL Advanced"] + types: [completed] permissions: pull-requests: write + issues: write checks: read + statuses: write concurrency: group: label-pr-review-state @@ -19,42 +31,99 @@ concurrency: jobs: reconcile: + name: Zoo Code / reconcile PR review state runs-on: ubuntu-latest steps: - name: Reconcile PR review state labels uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: + retries: 3 script: | const { owner, repo } = context.repo; - const stateLabels = ['awaiting-author', 'awaiting-review', 'has-conflicts']; + const stateLabels = [ + 'awaiting-author', + 'awaiting-coderabbit', + 'awaiting-ready', + 'awaiting-maintainer', + 'awaiting-review', // Legacy label removed during reconciliation. + 'has-conflicts', + ]; + const labelDefinitions = [ + { + name: 'awaiting-coderabbit', + color: '5319e7', + description: 'Waiting for CodeRabbit to approve the latest commit', + }, + { + name: 'awaiting-ready', + color: '1d76db', + description: 'CodeRabbit approved; waiting for the draft to be marked ready', + }, + { + name: 'awaiting-maintainer', + color: '0e8a16', + description: 'CodeRabbit approved; waiting for a human maintainer', + }, + { + name: 'coderabbit-review-active', + color: '5319e7', + description: 'Required CI passed; CodeRabbit review is active', + }, + ]; + const guideMarker = ''; + const codeRabbitLabelMarkerPrefix = '`); + const match = comment?.body?.match(markerPattern); + return match?.[1] ?? null; + } + + async function permissionFor(username) { + try { + const result = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, repo, username, + }); + return result.data.permission; + } catch (error) { + if (error.status === 404) return 'none'; + throw error; + } + } + + function phaseMessage(phase) { + const messages = { + draft: 'Mark the PR ready to start CodeRabbit after required CI passes.', + conflict: 'Resolve the merge conflicts. The review sequence resumes after the branch is mergeable.', + 'ci-pending': 'Wait for the required CI checks to finish.', + 'ci-failed': 'Fix the failing required CI checks and push an update.', + 'configuration-error': 'Repository rules must not require this advisory workflow\'s own gate or reconciliation job.', + 'coderabbit-changes': 'Address CodeRabbit findings and push an update. Review restarts after CI passes.', + coderabbit: 'Required CI passed. Wait for CodeRabbit to approve the latest commit.', + 'draft-approved': 'CodeRabbit approved the latest commit. Mark the draft ready.', + 'fork-approved': 'Fork review completed. Native GitHub review protections remain authoritative.', + 'maintainer-changes': 'Address the maintainer feedback, push an update, and request another review.', + maintainer: 'Ready for human maintainer review and approval.', + approved: 'The required review sequence passed. Remaining merge requirements apply.', + }; + return messages[phase]; + } + + async function updateReviewGate(pr, phase, passed, required = false) { + if (isReadOnlyRun && isForkPR(pr)) return; + + const state = passed ? 'success' : 'pending'; + const description = phaseMessage(phase); + let latestGateStatus = null; + let lookupSucceeded = false; + try { + const { data: combinedStatus } = await github.rest.repos.getCombinedStatusForRef({ + owner, repo, ref: pr.head.sha, + }); + lookupSucceeded = true; + latestGateStatus = combinedStatus.statuses + .filter(status => status.context === reviewGateName) + .sort((a, b) => b.id - a.id)[0]; + } catch (error) { + core.warning(`PR #${pr.number}: could not inspect ${reviewGateName}: ${error.message}`); + } + if (!lookupSucceeded && passed) return; + if (latestGateStatus?.state === state && + latestGateStatus.description === description && + latestGateStatus.target_url === pr.html_url) { + return; + } + const mustInvalidateSuccess = !passed && + (!lookupSucceeded || latestGateStatus?.state === 'success'); + + try { + await github.rest.repos.createCommitStatus({ + owner, + repo, + sha: pr.head.sha, + state, + context: reviewGateName, + description, + target_url: pr.html_url, + }); + } catch (error) { + if (required || mustInvalidateSuccess) throw error; + core.warning(`PR #${pr.number}: could not publish ${reviewGateName}: ${error.message}`); + } + } + + function reviewGuideBody(pr, phase, activationPending = false) { + const automatedAuthor = pr.user?.type === 'Bot'; + const authorNote = automatedAuthor + ? 'This PR was opened by an automated account. A human maintainer must verify the change intent, provenance, and validation before merging.' + : 'Thanks for contributing. This comment tracks the review sequence and the next action.'; + + const labelMarker = phase === 'coderabbit' + ? `\n${codeRabbitLabelMarkerPrefix}${pr.head.sha}${activationPending ? ':pending' : ''} -->` + : ''; + + return `${guideMarker}\n### Review process\n\n${authorNote}\n\n` + + '1. Required CI checks pass.\n' + + '2. The workflow starts CodeRabbit automatically.\n' + + '3. For eligible human-authored PRs, CodeRabbit reviews and approves the latest commit.\n' + + '4. A human maintainer reviews and approves after CodeRabbit.\n\n' + + `**Current step:** ${phaseMessage(phase)}${labelMarker}`; + } + + async function updateReviewGuide(pr, phase, existingGuide = null, activationPending = false) { + if (isReadOnlyRun && isForkPR(pr)) { + core.info(`PR #${pr.number}: fork PR on a read-only run — skipping guide update`); + return; + } + + const body = reviewGuideBody(pr, phase, activationPending); + const existing = existingGuide ?? await findReviewGuide(pr); + + if (!existing) { + const { data: created } = await github.rest.issues.createComment({ + owner, repo, issue_number: pr.number, body, + }); + return created; + } else if (existing.body !== body) { + await github.rest.issues.updateComment({ + owner, repo, comment_id: existing.id, body, + }); + return { ...existing, body }; } + return existing; } // Fetch required status check names from the branch ruleset. // Uses the public /rules/branches endpoint — no admin token needed. - // Falls back to blocking on all checks if the endpoint is unavailable. - let requiredCheckNames = null; + // Fails closed if the endpoint is unavailable. + let requiredChecks = null; try { - const { data: rules } = await github.request( + const rules = await github.paginate( 'GET /repos/{owner}/{repo}/rules/branches/{branch}', - { owner, repo, branch: 'main' }, + { owner, repo, branch: 'main', per_page: 100 }, ); - const statusRule = rules.find(r => r.type === 'required_status_checks'); - if (statusRule) { - requiredCheckNames = new Set( - statusRule.parameters.required_status_checks.map(c => c.context), + const statusRules = rules.filter(rule => rule.type === 'required_status_checks'); + requiredChecks = []; + if (statusRules.length > 0) { + const uniqueChecks = new Map(); + for (const rule of statusRules) { + for (const check of rule.parameters.required_status_checks) { + const integrationId = check.integration_id ?? null; + uniqueChecks.set(`${check.context}:${integrationId ?? 'any'}`, { + context: check.context, + integrationId, + }); + } + } + requiredChecks = [...uniqueChecks.values()]; + core.info( + `Required checks: ${requiredChecks.map(check => + `${check.context}${check.integrationId ? `@${check.integrationId}` : ''}` + ).join(', ')}` ); - core.info(`Required checks: ${[...requiredCheckNames].join(', ')}`); } } catch (err) { - core.warning(`Could not fetch branch rules, falling back to all checks: ${err.message}`); + core.warning(`Could not fetch branch rules; review gate remains pending: ${err.message}`); } const failures = []; for (const pr of prs) { + let failurePhase = 'ci-pending'; try { - // Draft PRs never get a state label. - if (pr.draft) { - core.info(`PR #${pr.number}: draft — stripping state labels`); + const selfReferentialRequirements = (requiredChecks ?? []).filter(check => + check.context === reviewGateName || check.context === reconciliationCheckName + ); + if (selfReferentialRequirements.length > 0) { + failurePhase = 'configuration-error'; + const contexts = selfReferentialRequirements.map(check => check.context).join(', '); + core.warning( + `PR #${pr.number}: unsupported self-referential required check configuration: ${contexts}` + ); + await updateReviewGate(pr, 'configuration-error', false, true); + const existingGuide = await findReviewGuide(pr); + await setCodeRabbitReviewActive(pr, false); await reconcileLabels(pr, null); + await updateReviewGuide(pr, 'configuration-error', existingGuide); continue; } + let existingGuide = await findReviewGuide(pr); + // `mergeable`/`mergeable_state` are only returned by the single-PR GET // endpoint, and are computed asynchronously by GitHub — a PR fetched via // pulls.list (schedule/workflow_dispatch runs) never has them, and even a // single-PR fetch can return `null`/"unknown" if the merge check hasn't // finished yet. Re-fetch the single PR to get a fresh value, and treat // "unknown" as not-yet-computed rather than as conflicting. - const prDetail = prNumber + const prDetail = eventPrNumbers.length > 0 ? pr : (await github.rest.pulls.get({ owner, repo, pull_number: pr.number })).data; if (prDetail.mergeable === false && prDetail.mergeable_state === 'dirty') { core.info(`PR #${pr.number}: has merge conflicts — labeling has-conflicts`); + await updateReviewGate(pr, 'conflict', false); + await setCodeRabbitReviewActive(pr, false); await reconcileLabels(pr, 'has-conflicts'); + await updateReviewGuide(pr, 'conflict', existingGuide); continue; } // Check CI status for required checks on the PR's head commit only. // Scoping to required checks avoids advisory checks (e.g. codecov/patch) // incorrectly blocking label assignment on otherwise-ready PRs. - const [checkRuns, commitStatusRes] = await Promise.all([ - github.paginate(github.rest.checks.listForRef, { + const checkRuns = await github.paginate(github.rest.checks.listForRef, { + owner, repo, ref: pr.head.sha, per_page: 100, + }); + const needsLegacyStatuses = requiredChecks?.some( + check => check.integrationId === null + ); + const commitStatuses = needsLegacyStatuses + ? await github.paginate(github.rest.repos.listCommitStatusesForRef, { owner, repo, ref: pr.head.sha, per_page: 100, - }), - github.rest.repos.getCombinedStatusForRef({ - owner, repo, ref: pr.head.sha, - }), - ]); + }) + : []; // listForRef returns every check run ever recorded on the ref, including // stale superseded ones (e.g. a failed run later re-run green). Branch @@ -165,52 +484,85 @@ jobs: // run can have started_at: null, which would lose a string comparison // against an older completed run's timestamp). const latestByName = new Map(); + const latestByNameAndApp = new Map(); for (const run of checkRuns) { const prev = latestByName.get(run.name); if (!prev || run.id > prev.id) { latestByName.set(run.name, run); } + const appKey = `${run.name}:${run.app?.id ?? 'none'}`; + const previousAppRun = latestByNameAndApp.get(appKey); + if (!previousAppRun || run.id > previousAppRun.id) { + latestByNameAndApp.set(appKey, run); + } } - // Filter to required checks only (or all checks if rules unavailable). - // Always exclude this workflow's own run to avoid self-referential loops. - const relevantRuns = [...latestByName.values()].filter(run => { - if (run.name === 'Reconcile PR review state labels') return false; - return requiredCheckNames ? requiredCheckNames.has(run.name) : true; - }); + const latestStatusByContext = new Map(); + for (const status of commitStatuses) { + const previous = latestStatusByContext.get(status.context); + if (!previous || status.id > previous.id) { + latestStatusByContext.set(status.context, status); + } + } - // For commit statuses (external CIs), there's no per-status name filtering - // available from getCombinedStatusForRef — it aggregates all statuses. - // If required checks are known, we only use commitStatus as a signal when - // no required check runs exist for this ref (i.e. pure status-based CI). - const useCommitStatus = !requiredCheckNames || relevantRuns.length === 0; + // Evaluate every required rule exactly as GitHub reports it. Workflow + // ownership cannot be inferred from the shared GitHub Actions app ID. + const requiredSpecs = requiredChecks ?? []; + const requiredResults = requiredSpecs.map(check => { + const run = check.integrationId === null + ? latestByName.get(check.context) + : latestByNameAndApp.get(`${check.context}:${check.integrationId}`); + const status = check.integrationId === null + ? latestStatusByContext.get(check.context) + : null; + return { check, run, status }; + }); + const relevantRuns = requiredResults.map(result => result.run).filter(Boolean); + const relevantStatuses = requiredResults.map(result => result.status).filter(Boolean); + const missingRequiredChecks = requiredResults + .filter(result => !result.run && !result.status) + .map(result => + `${result.check.context}${result.check.integrationId ? `@${result.check.integrationId}` : ''}` + ); - core.debug(`PR #${pr.number}: ${relevantRuns.length} required check run(s), commit status=${commitStatusRes.data.state} (used=${useCommitStatus})`); + core.debug( + `PR #${pr.number}: ${relevantRuns.length} check run(s), ` + + `${relevantStatuses.length} commit status(es), ` + + `missing=[${missingRequiredChecks.join(', ')}]` + ); for (const run of relevantRuns) { core.debug(` check: "${run.name}" status=${run.status} conclusion=${run.conclusion}`); } - const ciPending = relevantRuns.some( - run => run.status === 'queued' || run.status === 'in_progress', - ) || (useCommitStatus && commitStatusRes.data.state === 'pending'); - - const ciFailed = !ciPending && ( - relevantRuns.some( + const ciFailed = relevantRuns.some( run => run.status === 'completed' && run.conclusion !== 'success' && run.conclusion !== 'skipped' && run.conclusion !== 'neutral', - ) || (useCommitStatus && ( - commitStatusRes.data.state === 'failure' || - commitStatusRes.data.state === 'error' - )) + ) || relevantStatuses.some( + status => status.state === 'failure' || status.state === 'error' + ); + + const ciPending = !ciFailed && ( + requiredChecks === null || missingRequiredChecks.length > 0 || relevantRuns.some( + run => run.status !== 'completed', + ) || relevantStatuses.some( + status => status.state === 'pending' + ) ); // While CI is running or has failed, remove state labels and move on. // CI failure is its own signal; the label would add noise, not clarity. if (ciPending || ciFailed) { core.info(`PR #${pr.number}: CI ${ciPending ? 'pending' : 'failed'} — stripping state labels`); + await updateReviewGate(pr, ciPending ? 'ci-pending' : 'ci-failed', false); + await setCodeRabbitReviewActive(pr, false); await reconcileLabels(pr, null); + await updateReviewGuide( + pr, + ciPending ? 'ci-pending' : 'ci-failed', + existingGuide + ); continue; } @@ -225,50 +577,140 @@ jobs: // block the PR or indicate the author needs to act. const latest = new Map(); for (const r of reviews) { - if (r.state !== 'COMMENTED' && r.state !== 'DISMISSED') { - latest.set(r.user.login, r); + const reviewer = r.user.login.toLowerCase(); + if (r.state === 'DISMISSED') { + latest.delete(reviewer); + } else if (r.state !== 'COMMENTED') { + latest.set(reviewer, r); } } - const requestedReviewers = new Set( - pr.requested_reviewers.map(r => r.login), + const codeRabbitReview = latest.get(codeRabbitLogin); + const freshCodeRabbitReview = codeRabbitReview?.commit_id === pr.head.sha + ? codeRabbitReview + : null; + const freshMaintainerReviews = []; + for (const review of latest.values()) { + if (review.commit_id !== pr.head.sha || + review.user?.type === 'Bot' || + review.user?.login.toLowerCase() === pr.user?.login.toLowerCase()) { + continue; + } + if (['admin', 'maintain', 'write'].includes(await permissionFor(review.user.login))) { + freshMaintainerReviews.push(review); + } + } + const maintainerChangeRequest = freshMaintainerReviews.find( + review => review.state === 'CHANGES_REQUESTED' ); - - const changeRequesters = [...latest.entries()] - .filter(([, r]) => r.state === 'CHANGES_REQUESTED') - .map(([login]) => login); + const automatedAuthor = pr.user?.type === 'Bot'; + const codeRabbitApproved = freshCodeRabbitReview?.state === 'APPROVED'; + const codeRabbitChangesRequested = freshCodeRabbitReview?.state === 'CHANGES_REQUESTED'; + const maintainerApproval = freshMaintainerReviews + .filter(review => review.state === 'APPROVED') + .sort((a, b) => b.id - a.id)[0]; + const maintainerApprovedAfterCodeRabbit = codeRabbitApproved && + maintainerApproval && + maintainerApproval.id > freshCodeRabbitReview.id; let desiredLabel; - if (changeRequesters.length > 0) { - // If every change-requester has been re-requested for review, - // the author has addressed feedback and re-opened it for review. - desiredLabel = changeRequesters.every(login => requestedReviewers.has(login)) - ? 'awaiting-review' - : 'awaiting-author'; + let phase; + let activateCodeRabbit = false; + let recycleCodeRabbitLabel = false; + if (codeRabbitChangesRequested || maintainerChangeRequest) { + desiredLabel = 'awaiting-author'; + phase = codeRabbitChangesRequested ? 'coderabbit-changes' : 'maintainer-changes'; + } else if (automatedAuthor) { + if (pr.draft) { + desiredLabel = null; + phase = 'draft'; + } else if (!maintainerApproval) { + desiredLabel = 'awaiting-maintainer'; + phase = 'maintainer'; + } else { + desiredLabel = null; + phase = 'approved'; + } + } else if (!codeRabbitApproved) { + if (pr.draft) { + desiredLabel = null; + phase = 'draft'; + } else { + activateCodeRabbit = true; + recycleCodeRabbitLabel = codeRabbitLabelHead(existingGuide) !== pr.head.sha; + desiredLabel = 'awaiting-coderabbit'; + phase = 'coderabbit'; + } + } else if (pr.draft) { + desiredLabel = 'awaiting-ready'; + phase = 'draft-approved'; + } else if (!maintainerApprovedAfterCodeRabbit) { + desiredLabel = 'awaiting-maintainer'; + phase = 'maintainer'; } else { - // No outstanding change requests: awaiting first review, or all approved. - // awaiting-review if: there are pending requested reviewers, or nobody - // has given a meaningful review yet. null (approved) if everyone approved. - const allApproved = latest.size > 0 && - [...latest.values()].every(r => r.state === 'APPROVED') && - requestedReviewers.size === 0; - - desiredLabel = allApproved ? null : 'awaiting-review'; + desiredLabel = null; + phase = 'approved'; } core.info( `PR #${pr.number}: CI passing, reviews=${latest.size}, ` + - `changeRequesters=[${changeRequesters.join(',')}], ` + - `requestedReviewers=[${[...requestedReviewers].join(',')}] → ${desiredLabel ?? '(none)'}` + `coderabbit=${freshCodeRabbitReview?.state ?? (automatedAuthor ? 'optional' : 'pending')}, ` + + `maintainer=${maintainerApproval?.state ?? 'pending'} → ${desiredLabel ?? '(none)'}` ); + const readyForMaintainer = phase === 'maintainer' || phase === 'approved'; + if (!readyForMaintainer) { + await updateReviewGate(pr, phase, false); + } + const recyclingActiveLabel = activateCodeRabbit && recycleCodeRabbitLabel && + pr.labels.some(label => label.name === codeRabbitActiveLabel); + existingGuide = await updateReviewGuide( + pr, + phase, + existingGuide, + recyclingActiveLabel, + ); + await setCodeRabbitReviewActive(pr, activateCodeRabbit, recycleCodeRabbitLabel); + if (recyclingActiveLabel) { + await updateReviewGuide(pr, phase, existingGuide); + } await reconcileLabels(pr, desiredLabel); + if (readyForMaintainer) { + if (isForkPR(pr)) { + await updateReviewGate(pr, 'fork-approved', false); + } else { + await updateReviewGate(pr, phase, true); + } + } } catch (error) { + let invalidationError = null; + const metadataErrors = []; + try { + await updateReviewGate(pr, failurePhase, false, true); + } catch (gateError) { + invalidationError = gateError; + } + try { + await setCodeRabbitReviewActive(pr, false); + } catch (cleanupError) { + metadataErrors.push(cleanupError); + } + try { + await reconcileLabels(pr, null); + } catch (cleanupError) { + metadataErrors.push(cleanupError); + } const detail = error.status ? `${error.message} (HTTP ${error.status}${error.response?.data?.message ? `: ${error.response.data.message}` : ''})` : error.message; - failures.push(`#${pr.number}: ${detail}`); - core.error(`Failed to reconcile PR #${pr.number}: ${detail}`); + const gateDetail = invalidationError + ? `; could not invalidate ${reviewGateName}: ${invalidationError.message}` + : ''; + const metadataDetail = metadataErrors.length > 0 + ? `; could not clear review metadata: ${metadataErrors.map(err => err.message).join('; ')}` + : ''; + failures.push(`#${pr.number}: ${detail}${gateDetail}${metadataDetail}`); + core.error(`Failed to reconcile PR #${pr.number}: ${detail}${gateDetail}${metadataDetail}`); } } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ce81bd722..d6d5e3fcde 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -134,6 +134,10 @@ pnpm install ### Review Process +Ready-for-review PRs must pass required CI checks, address actionable review feedback, and receive maintainer approval. Automated review may add a guidance comment and managed state labels; contributors should follow the indicated next step rather than editing those labels directly. New commits may reset the review state for the updated code. + +Automated review supports maintainers but does not replace their judgment. Warnings are advisory unless repository policy says otherwise, and native GitHub required-check and review protections remain authoritative for merging. + - **Daily Triage:** Quick checks by maintainers. - **Weekly In-depth Review:** Comprehensive assessment. - **Iterate promptly** based on feedback. @@ -153,7 +157,7 @@ Maintainers may close PRs that are incomplete, too broad, inactive, not aligned PRs are also closed automatically by bot: - **60-day inactivity:** A PR with no activity for 60 days is marked stale and closed after a further 7 days if there is still no activity. Any new comment, commit, or review resets the timer. -- **14-day author inactivity:** After a reviewer requests changes, the PR is labelled `awaiting-author`. Author activity resets the inactivity timer. Once the changes are ready, re-request review from the reviewer; the PR will move to `awaiting-review` and is no longer eligible for automatic closure under this policy. +- **14-day author inactivity:** After a reviewer requests changes, the PR is labelled `awaiting-author`. Author activity resets the inactivity timer. Once the changes are ready, re-request review from the reviewer. To opt a PR out of automatic closure, apply the `do-not-close`, `pinned`, or `work-in-progress` label. diff --git a/src/services/__tests__/pr-review-state-workflow.test.ts b/src/services/__tests__/pr-review-state-workflow.test.ts new file mode 100644 index 0000000000..00122bd9c8 --- /dev/null +++ b/src/services/__tests__/pr-review-state-workflow.test.ts @@ -0,0 +1,1471 @@ +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +import { describe, expect, it, vi } from "vitest" +import { parse } from "yaml" + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..") +const workflow = parse( + fs.readFileSync(path.join(repositoryRoot, ".github/workflows/label-pr-review-state.yml"), "utf8"), +) +const workflowScript = workflow.jobs.reconcile.steps[0].with.script as string + +const SHA = "a".repeat(40) +const OLD_SHA = "b".repeat(40) +const REVIEWED_AT = Date.parse("2026-08-29T15:02:00Z") + +type ReviewState = "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED" + +interface HarnessOptions { + prState?: "open" | "closed" + draft?: boolean + conflict?: boolean + mergeable?: boolean | null + mergeableState?: string + fork?: boolean + eventName?: string + workflowRunAssociated?: boolean + workflowDispatchPrNumber?: number + existingGuide?: boolean + existingGuideHead?: string + existingGuidePendingHead?: string + labels?: string[] + prAuthor?: { login: string; type: "Bot" | "User" } + addLabelsStatus?: number + addLabelsFailOnceName?: string + labelLookupStatus?: number + createLabelStatus?: number + listCommentsErrorStatus?: number + createCommentErrorStatus?: number + updateCommentErrorStatus?: number + removeLabelStatus?: number + removeLabelFailOnceName?: string + reviews?: Array<{ + login: string + type: "Bot" | "User" + state: ReviewState + submittedAt: number + commitId?: string + }> + permissions?: Record + permissionErrorStatus?: number + requiredContexts?: string[] + requiredIntegrationId?: number | null + requiredRunAppId?: number + requiredStatus?: "queued" | "in_progress" | "completed" + requiredConclusion?: "success" | "failure" + omitRequiredRuns?: boolean + commitStatuses?: Array<{ context: string; state: "pending" | "success" | "failure" | "error"; id?: number }> + gateStatuses?: Array<{ + context: string + state: "pending" | "success" | "failure" | "error" + description: string + targetUrl: string + id?: number + }> + gateStatusLookupErrorStatus?: number + createCommitStatusErrorStatus?: number + includeFailedCodecov?: boolean + additionalCheckRuns?: Array<{ + id: number + name: string + status: "queued" | "in_progress" | "completed" + conclusion: "success" | "failure" | null + appId: number + }> + branchRulesFail?: boolean +} + +/** Executes the embedded github-script workflow against deterministic GitHub API doubles. */ +async function runWorkflow(options: HarnessOptions = {}) { + const headRepository = options.fork ? "contributor/Zoo-Code" : "Zoo-Code-Org/Zoo-Code" + const pr = { + number: 1437, + state: options.prState ?? "open", + draft: options.draft ?? false, + html_url: "https://github.com/Zoo-Code-Org/Zoo-Code/pull/1437", + user: options.prAuthor ?? { login: "contributor", type: "User" }, + head: { sha: SHA, repo: { full_name: headRepository } }, + base: { ref: "main", repo: { full_name: "Zoo-Code-Org/Zoo-Code" } }, + labels: (options.labels ?? []).map((name) => ({ name })), + mergeable: options.mergeable !== undefined ? options.mergeable : options.conflict ? false : true, + mergeable_state: options.mergeableState ?? (options.conflict ? "dirty" : "clean"), + } + const requiredContexts = options.requiredContexts ?? ["tests"] + const requiredRuns = (options.omitRequiredRuns ? [] : requiredContexts) + .filter((name) => name !== "Zoo Code / reconcile PR review state") + .map((name, index) => ({ + id: index + 1, + name, + status: options.requiredStatus ?? "completed", + conclusion: + (options.requiredStatus ?? "completed") === "completed" + ? (options.requiredConclusion ?? "success") + : null, + started_at: "2026-08-29T15:00:00Z", + completed_at: (options.requiredStatus ?? "completed") === "completed" ? "2026-08-29T15:01:00Z" : null, + app: { id: options.requiredRunAppId ?? 15368, slug: "github-actions" }, + })) + const checkRuns = [ + ...requiredRuns, + ...(options.additionalCheckRuns ?? []).map((run) => ({ + id: run.id, + name: run.name, + status: run.status, + conclusion: run.conclusion, + started_at: "2026-08-29T14:00:00Z", + completed_at: run.status === "completed" ? "2026-08-29T14:01:00Z" : null, + app: { id: run.appId, slug: "test-app" }, + })), + ...(options.includeFailedCodecov + ? [ + { + id: 100, + name: "codecov/patch", + status: "completed", + conclusion: "failure", + started_at: "2026-08-29T15:00:00Z", + completed_at: "2026-08-29T15:01:00Z", + app: { id: 254, slug: "codecov" }, + }, + ] + : []), + ] + const reviews = (options.reviews ?? []).map((review, index) => ({ + id: index + 1, + state: review.state, + commit_id: review.commitId ?? SHA, + submitted_at: new Date(review.submittedAt).toISOString(), + user: { login: review.login, type: review.type }, + })) + const existingComments = [ + ...(options.existingGuide || options.existingGuideHead || options.existingGuidePendingHead + ? [ + { + id: 10, + user: { login: "github-actions[bot]" }, + body: + "\n**Current step:** Waiting" + + (options.existingGuideHead + ? `\n` + : options.existingGuidePendingHead + ? `\n` + : ""), + }, + ] + : []), + ] + const remoteLabels = new Set(pr.labels.map((label) => label.name)) + + let addLabelsFailedOnce = false + const addLabels = vi.fn(async (args: { labels: string[] }) => { + if ( + options.addLabelsFailOnceName && + args.labels.includes(options.addLabelsFailOnceName) && + !addLabelsFailedOnce + ) { + addLabelsFailedOnce = true + throw Object.assign(new Error("Add label failed once"), { status: 500 }) + } + if (options.addLabelsStatus) { + throw Object.assign(new Error("Add labels failed"), { status: options.addLabelsStatus }) + } + for (const label of args.labels) remoteLabels.add(label) + }) + let removeLabelFailedOnce = false + const removeLabel = vi.fn(async (args: { name: string }) => { + if (options.removeLabelFailOnceName === args.name && !removeLabelFailedOnce) { + removeLabelFailedOnce = true + throw Object.assign(new Error("Remove label failed once"), { status: 500 }) + } + if (options.removeLabelStatus) { + throw Object.assign(new Error("Remove label failed"), { status: options.removeLabelStatus }) + } + remoteLabels.delete(args.name) + }) + const createComment = vi.fn(async (args: { body: string }) => { + if (options.createCommentErrorStatus) { + throw Object.assign(new Error("Create comment failed"), { status: options.createCommentErrorStatus }) + } + return { data: { id: 11, user: { login: "github-actions[bot]" }, body: args.body } } + }) + const updateComment = vi.fn(async (args: { comment_id: number; body: string }) => { + if (options.updateCommentErrorStatus) { + throw Object.assign(new Error("Update comment failed"), { status: options.updateCommentErrorStatus }) + } + return { data: { id: args.comment_id, user: { login: "github-actions[bot]" }, body: args.body } } + }) + const listComments = vi.fn(async () => { + if (options.listCommentsErrorStatus) { + throw Object.assign(new Error("List comments failed"), { status: options.listCommentsErrorStatus }) + } + return existingComments + }) + const createCommitStatus = vi.fn( + async (args: { sha: string; state: string; context: string; description: string; target_url: string }) => { + if (options.createCommitStatusErrorStatus) { + throw Object.assign(new Error("Commit status failed"), { + status: options.createCommitStatusErrorStatus, + }) + } + return { data: args } + }, + ) + const createLabel = vi.fn(async (_args: unknown) => { + if (options.createLabelStatus) { + throw Object.assign(new Error("Create label failed"), { status: options.createLabelStatus }) + } + }) + const setFailed = vi.fn() + const permissionFor = vi.fn(async ({ username }: { username: string }) => { + if (options.permissionErrorStatus) { + throw Object.assign(new Error("Permission lookup failed"), { status: options.permissionErrorStatus }) + } + const permission = options.permissions?.[username] + if (!permission) throw Object.assign(new Error("Not Found"), { status: 404 }) + return { data: { permission } } + }) + + const github = { + paginate: vi.fn(async (target: unknown, args: unknown) => { + if (typeof target === "string") { + if (options.branchRulesFail) throw new Error("rules unavailable") + return [ + { + type: "required_status_checks", + parameters: { + required_status_checks: requiredContexts.map((context) => ({ + context, + integration_id: + options.requiredIntegrationId === undefined ? 15368 : options.requiredIntegrationId, + })), + }, + }, + ] + } + if (typeof target !== "function") throw new Error("Unexpected paginate target") + return target(args) + }), + rest: { + pulls: { + get: vi.fn(async () => ({ data: pr })), + list: vi.fn(async () => [pr]), + listReviews: vi.fn(async () => reviews), + }, + issues: { + get: vi.fn(async () => ({ data: { labels: [...remoteLabels].map((name) => ({ name })) } })), + getLabel: vi.fn(async () => { + if (options.labelLookupStatus) { + throw Object.assign(new Error("Label lookup failed"), { status: options.labelLookupStatus }) + } + return { data: {} } + }), + createLabel, + removeLabel, + addLabels, + listComments, + createComment, + updateComment, + }, + checks: { + listForRef: vi.fn(async () => checkRuns), + }, + repos: { + createCommitStatus, + listCommitStatusesForRef: vi.fn(async () => + (options.commitStatuses ?? []).map((status, index) => ({ + id: status.id ?? index + 1, + context: status.context, + state: status.state, + created_at: "2026-08-29T15:00:00Z", + updated_at: "2026-08-29T15:01:00Z", + })), + ), + getCombinedStatusForRef: vi.fn(async () => { + if (options.gateStatusLookupErrorStatus) { + throw Object.assign(new Error("Gate status lookup failed"), { + status: options.gateStatusLookupErrorStatus, + }) + } + return { + data: { + statuses: (options.gateStatuses ?? []).map((status, index) => ({ + id: status.id ?? index + 1, + context: status.context, + state: status.state, + description: status.description, + target_url: status.targetUrl, + })), + }, + } + }), + getCollaboratorPermissionLevel: permissionFor, + }, + }, + } + const eventName = options.eventName ?? "pull_request_target" + const pullRequestPayload = { + number: 1437, + head: { repo: { full_name: headRepository } }, + base: { repo: { full_name: "Zoo-Code-Org/Zoo-Code" } }, + } + const payload = + eventName === "schedule" + ? {} + : eventName === "workflow_dispatch" + ? { inputs: { pull_request_number: String(options.workflowDispatchPrNumber ?? 1437) } } + : eventName === "workflow_run" + ? { + workflow_run: { + pull_requests: options.workflowRunAssociated === false ? [] : [{ number: 1437 }], + }, + } + : { + action: "ready_for_review", + pull_request: pullRequestPayload, + } + const context = { + eventName, + repo: { owner: "Zoo-Code-Org", repo: "Zoo-Code" }, + payload, + } + const core = { + info: vi.fn(), + debug: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed, + } + + await new AsyncFunction("github", "context", "core", workflowScript)(github, context, core) + + return { + addLabels, + removeLabel, + createComment, + updateComment, + listComments, + createCommitStatus, + createLabel, + setFailed, + warning: core.warning, + listPullRequests: github.rest.pulls.list, + listCommitStatusesForRef: github.rest.repos.listCommitStatusesForRef, + } +} + +/** Returns the most recently created or updated managed guidance comment body. */ +function latestGuide(result: Awaited>) { + const created = result.createComment.mock.calls.at(-1)?.[0] + const updated = result.updateComment.mock.calls.at(-1)?.[0] + return updated?.body ?? created?.body ?? "" +} + +/** Returns the latest advisory gate commit-status payload. */ +function latestGateStatus(result: Awaited>) { + return result.createCommitStatus.mock.calls.at(-1)?.[0] +} + +describe("PR review-state workflow", () => { + it("ignores events for closed pull requests", async () => { + const result = await runWorkflow({ prState: "closed" }) + + expect(result.addLabels).not.toHaveBeenCalled() + expect(result.removeLabel).not.toHaveBeenCalled() + expect(result.createComment).not.toHaveBeenCalled() + expect(result.updateComment).not.toHaveBeenCalled() + expect(result.createCommitStatus).not.toHaveBeenCalled() + expect(result.setFailed).not.toHaveBeenCalled() + }) + + it("keeps fork review events read-only", async () => { + const result = await runWorkflow({ eventName: "pull_request_review", fork: true }) + + expect(result.addLabels).not.toHaveBeenCalled() + expect(result.removeLabel).not.toHaveBeenCalled() + expect(result.createComment).not.toHaveBeenCalled() + expect(result.updateComment).not.toHaveBeenCalled() + expect(result.createCommitStatus).not.toHaveBeenCalled() + expect(result.setFailed).not.toHaveBeenCalled() + }) + + it("reconciles same-repository review events", async () => { + const result = await runWorkflow({ eventName: "pull_request_review" }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + expect(result.createCommitStatus).toHaveBeenCalled() + }) + + it("reconciles fork PRs from pull_request_target", async () => { + const result = await runWorkflow({ eventName: "pull_request_target", fork: true }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + expect(result.createCommitStatus).toHaveBeenCalled() + expect(result.setFailed).not.toHaveBeenCalled() + }) + + it("creates missing workflow labels", async () => { + const result = await runWorkflow({ labelLookupStatus: 404 }) + + expect(result.createLabel).toHaveBeenCalledTimes(4) + }) + + it("fails closed when a managed label cannot be created", async () => { + await expect(runWorkflow({ labelLookupStatus: 404, createLabelStatus: 500 })).rejects.toThrow( + "Create label failed", + ) + }) + + it("propagates non-404 label lookup failures", async () => { + await expect(runWorkflow({ labelLookupStatus: 500 })).rejects.toThrow("Label lookup failed") + }) + + it("does not start automatic review for drafts", async () => { + const result = await runWorkflow({ draft: true }) + + expect(result.addLabels).not.toHaveBeenCalled() + expect(latestGuide(result)).toContain("Mark the PR ready") + }) + + it("routes bot-authored PRs directly to maintainer review", async () => { + const result = await runWorkflow({ + prAuthor: { login: "zoomote[bot]", type: "Bot" }, + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-maintainer"] })) + expect(result.addLabels).not.toHaveBeenCalledWith( + expect.objectContaining({ labels: ["coderabbit-review-active"] }), + ) + expect(latestGateStatus(result)?.state).toBe("success") + expect(latestGateStatus(result)?.description).toContain("Ready for human maintainer") + }) + + it("completes bot-authored PR review after human maintainer approval", async () => { + const result = await runWorkflow({ + prAuthor: { login: "zoomote[bot]", type: "Bot" }, + permissions: { maintainer: "write" }, + reviews: [ + { + login: "maintainer", + type: "User", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + ], + }) + + expect(latestGateStatus(result)?.state).toBe("success") + }) + + it("honors manually requested CodeRabbit changes on bot-authored PRs", async () => { + const result = await runWorkflow({ + prAuthor: { login: "zoomote[bot]", type: "Bot" }, + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "CHANGES_REQUESTED", + submittedAt: REVIEWED_AT, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + expect(latestGateStatus(result)?.state).toBe("pending") + }) + + it("removes the CodeRabbit label while required CI is pending", async () => { + const result = await runWorkflow({ + labels: ["coderabbit-review-active"], + requiredStatus: "in_progress", + }) + + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "coderabbit-review-active" })) + expect(latestGateStatus(result)?.description).toContain("required CI checks") + }) + + it("does not start CodeRabbit when required CI fails", async () => { + const result = await runWorkflow({ requiredConclusion: "failure" }) + + expect(result.addLabels).not.toHaveBeenCalled() + expect(latestGateStatus(result)?.description).toContain("Fix the failing required CI checks") + }) + + it("starts CodeRabbit automatically after required CI passes", async () => { + const result = await runWorkflow() + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-coderabbit"] })) + expect(latestGuide(result)).toContain(`coderabbit-review-label:${SHA}`) + }) + + it("invalidates the gate before fallible metadata updates", async () => { + const result = await runWorkflow({ addLabelsStatus: 500 }) + + expect(result.setFailed).toHaveBeenCalled() + expect(latestGateStatus(result)?.state).toBe("pending") + expect(result.createCommitStatus.mock.invocationCallOrder[0]).toBeLessThan( + result.addLabels.mock.invocationCallOrder[0], + ) + expect(result.createComment.mock.invocationCallOrder[0]).toBeLessThan( + result.addLabels.mock.invocationCallOrder[0], + ) + }) + + it("recycles a CodeRabbit label left over from an older head", async () => { + const result = await runWorkflow({ + labels: ["coderabbit-review-active"], + existingGuideHead: OLD_SHA, + }) + + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "coderabbit-review-active" })) + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + }) + + it.each(["awaiting-ready", "awaiting-maintainer"])("removes stale %s state", async (staleLabel) => { + const result = await runWorkflow({ labels: [staleLabel] }) + + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: staleLabel })) + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-coderabbit"] })) + }) + + it("removes stale awaiting-coderabbit after CodeRabbit approval", async () => { + const result = await runWorkflow({ + labels: ["awaiting-coderabbit"], + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + ], + }) + + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "awaiting-coderabbit" })) + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-maintainer"] })) + }) + + it("retries a CodeRabbit label recycle left in the pending state", async () => { + const result = await runWorkflow({ + labels: ["coderabbit-review-active"], + existingGuidePendingHead: SHA, + }) + + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "coderabbit-review-active" })) + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + expect(latestGuide(result)).toContain(`coderabbit-review-label:${SHA} -->`) + expect(latestGuide(result)).not.toContain(":pending") + }) + + it("recovers a recycled CodeRabbit activation label after one failed add", async () => { + const result = await runWorkflow({ + labels: ["coderabbit-review-active"], + existingGuideHead: OLD_SHA, + addLabelsFailOnceName: "coderabbit-review-active", + }) + + expect( + result.addLabels.mock.calls.filter(([args]) => args.labels.includes("coderabbit-review-active")), + ).toHaveLength(2) + expect(result.setFailed).not.toHaveBeenCalled() + expect(latestGuide(result)).toContain(`coderabbit-review-label:${SHA} -->`) + expect(latestGuide(result)).not.toContain(":pending") + }) + + it("fails closed when a recycled CodeRabbit activation label cannot be restored", async () => { + const result = await runWorkflow({ + labels: ["coderabbit-review-active"], + existingGuideHead: OLD_SHA, + addLabelsStatus: 500, + }) + + expect( + result.addLabels.mock.calls.filter(([args]) => args.labels.includes("coderabbit-review-active")), + ).toHaveLength(2) + expect(result.setFailed).toHaveBeenCalledWith(expect.stringContaining("Add labels failed")) + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "coderabbit-review-active" })) + expect(latestGuide(result)).toContain(`coderabbit-review-label:${SHA}:pending -->`) + }) + + it("tolerates an already-removed CodeRabbit label while recycling", async () => { + const result = await runWorkflow({ + labels: ["coderabbit-review-active"], + existingGuideHead: OLD_SHA, + removeLabelStatus: 404, + }) + + expect(result.setFailed).not.toHaveBeenCalled() + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + }) + + it("keeps a CodeRabbit label already bound to the current head", async () => { + const result = await runWorkflow({ + labels: ["coderabbit-review-active"], + existingGuideHead: SHA, + }) + + expect(result.removeLabel).not.toHaveBeenCalled() + expect(result.addLabels).not.toHaveBeenCalledWith( + expect.objectContaining({ labels: ["coderabbit-review-active"] }), + ) + }) + + it("ignores a failed optional Codecov check", async () => { + const result = await runWorkflow({ includeFailedCodecov: true }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + }) + + it("uses the latest run for the required integration", async () => { + const result = await runWorkflow({ + additionalCheckRuns: [{ id: 0, name: "tests", status: "completed", conclusion: "failure", appId: 15368 }], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + }) + + it("ignores a newer same-name run from another integration", async () => { + const result = await runWorkflow({ + additionalCheckRuns: [{ id: 100, name: "tests", status: "completed", conclusion: "failure", appId: 999 }], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + }) + + it("allows an empty required-check ruleset", async () => { + const result = await runWorkflow({ requiredContexts: [] }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + }) + + it("does not treat unknown mergeability as a conflict", async () => { + const result = await runWorkflow({ mergeable: null, mergeableState: "unknown" }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + expect(result.addLabels).not.toHaveBeenCalledWith(expect.objectContaining({ labels: ["has-conflicts"] })) + }) + + it("uses a legacy commit status for an unpinned required context", async () => { + const result = await runWorkflow({ + requiredIntegrationId: null, + omitRequiredRuns: true, + commitStatuses: [{ context: "tests", state: "success" }], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + }) + + it("uses only the latest legacy status for a required context", async () => { + const result = await runWorkflow({ + requiredIntegrationId: null, + omitRequiredRuns: true, + commitStatuses: [ + { id: 1, context: "tests", state: "failure" }, + { id: 2, context: "tests", state: "success" }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + }) + + it("blocks an unpinned context when its check passes but its status fails", async () => { + const result = await runWorkflow({ + requiredIntegrationId: null, + commitStatuses: [{ context: "tests", state: "failure" }], + }) + + expect(result.addLabels).not.toHaveBeenCalledWith( + expect.objectContaining({ labels: ["coderabbit-review-active"] }), + ) + expect(latestGateStatus(result)?.description).toContain("failing required CI checks") + }) + + it("keeps an unpinned context pending when its check passes but its status is pending", async () => { + const result = await runWorkflow({ + requiredIntegrationId: null, + commitStatuses: [{ context: "tests", state: "pending" }], + }) + + expect(result.addLabels).not.toHaveBeenCalledWith( + expect.objectContaining({ labels: ["coderabbit-review-active"] }), + ) + expect(latestGateStatus(result)?.description).toContain("required CI checks") + }) + + it("reports failure when an unpinned check is pending but its status failed", async () => { + const result = await runWorkflow({ + requiredIntegrationId: null, + requiredStatus: "in_progress", + commitStatuses: [{ context: "tests", state: "failure" }], + }) + + expect(latestGateStatus(result)?.description).toContain("failing required CI checks") + }) + + it("reports failure when an unpinned check failed but its status is pending", async () => { + const result = await runWorkflow({ + requiredIntegrationId: null, + requiredConclusion: "failure", + commitStatuses: [{ context: "tests", state: "pending" }], + }) + + expect(latestGateStatus(result)?.description).toContain("failing required CI checks") + }) + + it("does not use a legacy status for an integration-pinned check", async () => { + const result = await runWorkflow({ + requiredRunAppId: 999, + commitStatuses: [{ context: "tests", state: "success" }], + }) + + expect(result.addLabels).not.toHaveBeenCalledWith( + expect.objectContaining({ labels: ["coderabbit-review-active"] }), + ) + expect(result.listCommitStatusesForRef).not.toHaveBeenCalled() + expect(latestGateStatus(result)?.description).toContain("required CI checks") + }) + + it("keeps an external same-name review gate pending when it has not reported", async () => { + const result = await runWorkflow({ + requiredContexts: ["PR review gate"], + requiredIntegrationId: 15368, + omitRequiredRuns: true, + }) + + expect(result.addLabels).not.toHaveBeenCalledWith( + expect.objectContaining({ labels: ["coderabbit-review-active"] }), + ) + expect(latestGateStatus(result)?.description).toContain("required CI checks") + }) + + it("keeps an external same-name review gate blocked when it fails", async () => { + const result = await runWorkflow({ + requiredContexts: ["PR review gate"], + requiredIntegrationId: 15368, + requiredRunAppId: 15368, + requiredConclusion: "failure", + }) + + expect(result.addLabels).not.toHaveBeenCalledWith( + expect.objectContaining({ labels: ["coderabbit-review-active"] }), + ) + expect(latestGateStatus(result)?.description).toContain("failing required CI checks") + }) + + it("keeps another workflow's reconcile check pending when it has not reported", async () => { + const result = await runWorkflow({ + requiredContexts: ["reconcile"], + requiredIntegrationId: 15368, + omitRequiredRuns: true, + }) + + expect(result.addLabels).not.toHaveBeenCalledWith( + expect.objectContaining({ labels: ["coderabbit-review-active"] }), + ) + expect(latestGateStatus(result)?.description).toContain("required CI checks") + }) + + it("keeps another workflow's reconcile check blocked when it fails", async () => { + const result = await runWorkflow({ + requiredContexts: ["reconcile"], + requiredIntegrationId: 15368, + requiredRunAppId: 15368, + requiredConclusion: "failure", + }) + + expect(result.addLabels).not.toHaveBeenCalledWith( + expect.objectContaining({ labels: ["coderabbit-review-active"] }), + ) + expect(latestGateStatus(result)?.description).toContain("failing required CI checks") + }) + + it("keeps the gate pending when a required check has not reported", async () => { + const result = await runWorkflow({ omitRequiredRuns: true }) + + expect(result.addLabels).not.toHaveBeenCalledWith( + expect.objectContaining({ labels: ["coderabbit-review-active"] }), + ) + expect(latestGateStatus(result)?.description).toContain("required CI checks") + }) + + it("removes CodeRabbit activation when the PR has conflicts", async () => { + const result = await runWorkflow({ + conflict: true, + labels: ["coderabbit-review-active"], + }) + + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "coderabbit-review-active" })) + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["has-conflicts"] })) + }) + + it("routes CodeRabbit change requests back to the author", async () => { + const result = await runWorkflow({ + labels: ["coderabbit-review-active"], + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "CHANGES_REQUESTED", + submittedAt: REVIEWED_AT, + }, + ], + }) + + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "coderabbit-review-active" })) + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + }) + + it("moves approved ready PRs to maintainer review", async () => { + const result = await runWorkflow({ + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-maintainer"] })) + }) + + it("recognizes CodeRabbit regardless of login casing", async () => { + const result = await runWorkflow({ + reviews: [ + { + login: "CodeRabbitAI[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-maintainer"] })) + }) + + it("preserves CodeRabbit approval after a later comment", async () => { + const result = await runWorkflow({ + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + { + login: "coderabbitai[bot]", + type: "Bot", + state: "COMMENTED", + submittedAt: REVIEWED_AT + 1_000, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-maintainer"] })) + }) + + it("invalidates a dismissed CodeRabbit approval", async () => { + const result = await runWorkflow({ + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + { + login: "coderabbitai[bot]", + type: "Bot", + state: "DISMISSED", + submittedAt: REVIEWED_AT + 1_000, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-coderabbit"] })) + }) + + it("preserves manual draft approvals until the PR is ready", async () => { + const result = await runWorkflow({ + draft: true, + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-ready"] })) + }) + + it("treats non-collaborator reviews as non-maintainer input", async () => { + const result = await runWorkflow({ + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + { + login: "drive-by-reviewer", + type: "User", + state: "APPROVED", + submittedAt: REVIEWED_AT + 1_000, + }, + ], + }) + + expect(result.setFailed).not.toHaveBeenCalled() + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-maintainer"] })) + }) + + it("does not count the PR author's own approval", async () => { + const result = await runWorkflow({ + prAuthor: { login: "author", type: "User" }, + permissions: { author: "write" }, + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + { + login: "Author", + type: "User", + state: "APPROVED", + submittedAt: REVIEWED_AT + 1_000, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-maintainer"] })) + expect(latestGateStatus(result)?.state).toBe("success") + }) + + it("ignores maintainer approvals from an older head", async () => { + const result = await runWorkflow({ + permissions: { maintainer: "write" }, + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + { + login: "maintainer", + type: "User", + state: "APPROVED", + submittedAt: REVIEWED_AT + 1_000, + commitId: OLD_SHA, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-maintainer"] })) + expect(latestGateStatus(result)?.state).toBe("success") + }) + + it("keeps awaiting-author when any maintainer requests changes", async () => { + const result = await runWorkflow({ + permissions: { reviewer: "write", approver: "maintain" }, + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + { + login: "reviewer", + type: "User", + state: "CHANGES_REQUESTED", + submittedAt: REVIEWED_AT + 1_000, + }, + { + login: "approver", + type: "User", + state: "APPROVED", + submittedAt: REVIEWED_AT + 2_000, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + expect(latestGateStatus(result)?.state).toBe("pending") + }) + + it("keeps draft PRs awaiting the author when a maintainer requests changes", async () => { + const result = await runWorkflow({ + draft: true, + permissions: { maintainer: "write" }, + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + { + login: "maintainer", + type: "User", + state: "CHANGES_REQUESTED", + submittedAt: REVIEWED_AT + 1_000, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + }) + + it("passes only after a later non-author maintainer approval", async () => { + const result = await runWorkflow({ + permissions: { maintainer: "write" }, + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + { + login: "maintainer", + type: "User", + state: "APPROVED", + submittedAt: REVIEWED_AT + 1_000, + }, + ], + }) + + expect(latestGateStatus(result)?.state).toBe("success") + }) + + it("uses review order when approvals share the same timestamp", async () => { + const result = await runWorkflow({ + permissions: { maintainer: "write" }, + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + { + login: "maintainer", + type: "User", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + ], + }) + + expect(latestGateStatus(result)?.state).toBe("success") + }) + + it("passes once CodeRabbit approval makes the PR ready for maintainer review", async () => { + const result = await runWorkflow({ + permissions: { maintainer: "write" }, + reviews: [ + { + login: "maintainer", + type: "User", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT + 1_000, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-maintainer"] })) + expect(latestGateStatus(result)?.state).toBe("success") + }) + + it("publishes the review gate as a standalone commit status", async () => { + const result = await runWorkflow() + + expect(latestGateStatus(result)).toEqual( + expect.objectContaining({ context: "Zoo Code / PR review gate", sha: SHA, state: "pending" }), + ) + }) + + it("does not republish an unchanged review gate status", async () => { + const result = await runWorkflow({ + gateStatuses: [ + { + context: "Zoo Code / PR review gate", + state: "pending", + description: "Required CI passed. Wait for CodeRabbit to approve the latest commit.", + targetUrl: "https://github.com/Zoo-Code-Org/Zoo-Code/pull/1437", + }, + ], + }) + + expect(result.createCommitStatus).not.toHaveBeenCalled() + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-coderabbit"] })) + }) + + it("continues metadata reconciliation when gate publication fails", async () => { + const result = await runWorkflow({ createCommitStatusErrorStatus: 500 }) + + expect(result.setFailed).not.toHaveBeenCalled() + expect(result.warning).toHaveBeenCalledWith( + expect.stringContaining("could not publish Zoo Code / PR review gate"), + ) + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + expect(latestGuide(result)).toContain(`coderabbit-review-label:${SHA}`) + }) + + it("fails closed when a successful gate cannot be invalidated", async () => { + const result = await runWorkflow({ + createCommitStatusErrorStatus: 500, + gateStatuses: [ + { + context: "Zoo Code / PR review gate", + state: "success", + description: "Approved", + targetUrl: "https://github.com/Zoo-Code-Org/Zoo-Code/pull/1437", + }, + ], + }) + + expect(result.setFailed).toHaveBeenCalledWith( + expect.stringContaining("could not invalidate Zoo Code / PR review gate"), + ) + }) + + it("fails closed when review-guide comments cannot be listed", async () => { + const result = await runWorkflow({ listCommentsErrorStatus: 500 }) + + expect(result.setFailed).toHaveBeenCalledWith(expect.stringContaining("List comments failed")) + expect(latestGateStatus(result)?.state).toBe("pending") + }) + + it("fails closed when the review-guide comment cannot be created", async () => { + const result = await runWorkflow({ createCommentErrorStatus: 500 }) + + expect(result.setFailed).toHaveBeenCalledWith(expect.stringContaining("Create comment failed")) + expect(latestGateStatus(result)?.state).toBe("pending") + }) + + it("fails closed when the review-guide comment cannot be updated", async () => { + const result = await runWorkflow({ existingGuide: true, updateCommentErrorStatus: 500 }) + + expect(result.setFailed).toHaveBeenCalledWith(expect.stringContaining("Update comment failed")) + expect(latestGateStatus(result)?.state).toBe("pending") + }) + + it("publishes a pending gate and continues reconciliation when status lookup fails", async () => { + const result = await runWorkflow({ gateStatusLookupErrorStatus: 500 }) + + expect(result.setFailed).not.toHaveBeenCalled() + expect(result.warning).toHaveBeenCalledWith( + expect.stringContaining("could not inspect Zoo Code / PR review gate"), + ) + expect(latestGateStatus(result)?.state).toBe("pending") + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + }) + + it("does not publish success when gate status lookup fails at the maintainer handoff", async () => { + const result = await runWorkflow({ + gateStatusLookupErrorStatus: 500, + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + ], + }) + + expect(result.setFailed).not.toHaveBeenCalled() + expect(result.warning).toHaveBeenCalledWith( + expect.stringContaining("could not inspect Zoo Code / PR review gate"), + ) + expect(result.createCommitStatus).not.toHaveBeenCalledWith(expect.objectContaining({ state: "success" })) + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-maintainer"] })) + }) + + it("forces gate invalidation after a reconciliation failure even when status lookup fails", async () => { + const result = await runWorkflow({ + gateStatusLookupErrorStatus: 500, + permissionErrorStatus: 500, + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + { + login: "maintainer", + type: "User", + state: "APPROVED", + submittedAt: REVIEWED_AT + 1_000, + }, + ], + }) + + expect(result.setFailed).toHaveBeenCalled() + expect(latestGateStatus(result)?.state).toBe("pending") + }) + + it("reports non-404 permission lookup failures", async () => { + const result = await runWorkflow({ + labels: ["awaiting-maintainer", "coderabbit-review-active"], + gateStatuses: [ + { + context: "Zoo Code / PR review gate", + state: "success", + description: "Approved", + targetUrl: "https://github.com/Zoo-Code-Org/Zoo-Code/pull/1437", + }, + ], + permissionErrorStatus: 500, + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + { + login: "maintainer", + type: "User", + state: "APPROVED", + submittedAt: REVIEWED_AT + 1_000, + }, + ], + }) + + expect(result.setFailed).toHaveBeenCalled() + expect(latestGateStatus(result)?.state).toBe("pending") + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "awaiting-maintainer" })) + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "coderabbit-review-active" })) + }) + + it("fails closed when repository rules require the advisory reconciliation job", async () => { + const result = await runWorkflow({ + requiredContexts: ["tests", "Zoo Code / reconcile PR review state"], + }) + + expect(result.addLabels).not.toHaveBeenCalledWith( + expect.objectContaining({ labels: ["coderabbit-review-active"] }), + ) + expect(result.warning).toHaveBeenCalledWith(expect.stringContaining("self-referential required check")) + expect(latestGateStatus(result)?.description).toContain("must not require") + }) + + it("fails closed for a self-referential reconciliation rule from any integration", async () => { + const result = await runWorkflow({ + requiredContexts: ["Zoo Code / reconcile PR review state"], + requiredIntegrationId: 999, + omitRequiredRuns: true, + }) + + expect(result.addLabels).not.toHaveBeenCalledWith( + expect.objectContaining({ labels: ["coderabbit-review-active"] }), + ) + expect(result.warning).toHaveBeenCalledWith(expect.stringContaining("self-referential required check")) + expect(latestGateStatus(result)?.description).toContain("must not require") + }) + + it("fails closed when repository rules require the advisory review gate", async () => { + const result = await runWorkflow({ + requiredContexts: ["Zoo Code / PR review gate"], + }) + + expect(result.addLabels).not.toHaveBeenCalledWith( + expect.objectContaining({ labels: ["coderabbit-review-active"] }), + ) + expect(result.warning).toHaveBeenCalledWith(expect.stringContaining("self-referential required check")) + expect(latestGateStatus(result)?.description).toContain("must not require") + }) + + it("reports self-referential configuration before merge conflicts", async () => { + const result = await runWorkflow({ + conflict: true, + labels: ["coderabbit-review-active", "awaiting-maintainer"], + requiredContexts: ["Zoo Code / PR review gate"], + }) + + expect(result.warning).toHaveBeenCalledWith(expect.stringContaining("self-referential required check")) + expect(latestGateStatus(result)?.description).toContain("must not require") + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "coderabbit-review-active" })) + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "awaiting-maintainer" })) + expect(result.addLabels).not.toHaveBeenCalledWith(expect.objectContaining({ labels: ["has-conflicts"] })) + }) + + it("preserves configuration-error when review-guide lookup fails", async () => { + const result = await runWorkflow({ + requiredContexts: ["Zoo Code / PR review gate"], + listCommentsErrorStatus: 500, + }) + + expect(result.setFailed).toHaveBeenCalledWith(expect.stringContaining("List comments failed")) + expect(latestGateStatus(result)?.description).toContain("must not require") + expect(result.createCommitStatus.mock.invocationCallOrder[0]).toBeLessThan( + result.listComments.mock.invocationCallOrder[0], + ) + }) + + it("preserves configuration-error when metadata cleanup fails", async () => { + const result = await runWorkflow({ + requiredContexts: ["Zoo Code / PR review gate"], + labels: ["coderabbit-review-active", "awaiting-maintainer"], + removeLabelStatus: 500, + }) + + expect(result.setFailed).toHaveBeenCalledWith(expect.stringContaining("could not clear review metadata")) + expect(latestGateStatus(result)?.description).toContain("must not require") + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "coderabbit-review-active" })) + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "awaiting-maintainer" })) + }) + + it("attempts every stale label removal when one cleanup fails", async () => { + const result = await runWorkflow({ + permissionErrorStatus: 500, + labels: ["awaiting-coderabbit", "awaiting-maintainer", "stale-awaiting-author"], + removeLabelStatus: 500, + }) + + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "awaiting-coderabbit" })) + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "awaiting-maintainer" })) + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "stale-awaiting-author" })) + expect(result.setFailed).toHaveBeenCalledWith(expect.stringContaining("could not clear review metadata")) + }) + + it("outer cleanup removes a label added before partial reconciliation failure", async () => { + const result = await runWorkflow({ + labels: ["has-conflicts"], + removeLabelFailOnceName: "has-conflicts", + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-coderabbit"] })) + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "awaiting-coderabbit" })) + expect(result.setFailed).toHaveBeenCalledWith(expect.stringContaining("Remove label failed once")) + }) + + it("does not exclude a reconcile check from another integration", async () => { + const result = await runWorkflow({ + requiredContexts: ["reconcile"], + requiredIntegrationId: 999, + requiredRunAppId: 15368, + }) + + expect(result.addLabels).not.toHaveBeenCalledWith( + expect.objectContaining({ labels: ["coderabbit-review-active"] }), + ) + expect(latestGateStatus(result)?.description).toContain("required CI checks") + }) + + it("accepts a completed reconcile check from another integration", async () => { + const result = await runWorkflow({ + requiredContexts: ["reconcile"], + requiredIntegrationId: 999, + additionalCheckRuns: [ + { id: 100, name: "reconcile", status: "completed", conclusion: "success", appId: 999 }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + }) + + it("keeps fork review gates pending after approval", async () => { + const result = await runWorkflow({ + eventName: "schedule", + fork: true, + permissions: { maintainer: "write" }, + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + }, + { + login: "maintainer", + type: "User", + state: "APPROVED", + submittedAt: REVIEWED_AT + 1_000, + }, + ], + }) + + expect(latestGateStatus(result)?.state).toBe("pending") + expect(latestGateStatus(result)?.description).toContain("Native GitHub review protections") + }) + + it("fails closed when branch rules are unavailable", async () => { + const result = await runWorkflow({ branchRulesFail: true }) + + expect(result.addLabels).not.toHaveBeenCalled() + expect(latestGateStatus(result)?.description).toContain("required CI checks") + }) + + it("lists open PRs during scheduled reconciliation", async () => { + const result = await runWorkflow({ eventName: "schedule" }) + + expect(result.listPullRequests).toHaveBeenCalled() + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + }) + + it("reconciles only the requested PR during manual dispatch", async () => { + const result = await runWorkflow({ eventName: "workflow_dispatch", workflowDispatchPrNumber: 1437 }) + + expect(result.listPullRequests).not.toHaveBeenCalled() + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + }) + + it("reconciles the PR associated with a workflow run", async () => { + const result = await runWorkflow({ eventName: "workflow_run" }) + + expect(result.listPullRequests).not.toHaveBeenCalled() + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + }) + + it("ignores closed PRs associated with workflow runs", async () => { + const result = await runWorkflow({ eventName: "workflow_run", prState: "closed" }) + + expect(result.createCommitStatus).not.toHaveBeenCalled() + expect(result.addLabels).not.toHaveBeenCalled() + }) + + it("does not list every PR when a workflow run has no associated PR", async () => { + const result = await runWorkflow({ eventName: "workflow_run", workflowRunAssociated: false }) + + expect(result.listPullRequests).not.toHaveBeenCalled() + expect(result.createCommitStatus).not.toHaveBeenCalled() + }) + + it("ignores CodeRabbit reviews from an older head", async () => { + const result = await runWorkflow({ + reviews: [ + { + login: "coderabbitai[bot]", + type: "Bot", + state: "APPROVED", + submittedAt: REVIEWED_AT, + commitId: OLD_SHA, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["coderabbit-review-active"] })) + }) +})