Skip to content

feat(gui): source brand marks for 23 providers that rendered a letter tile #34701

feat(gui): source brand marks for 23 providers that rendered a letter tile

feat(gui): source brand marks for 23 providers that rendered a letter tile #34701

name: Enforce PR target branch
on:
pull_request_target:
types:
- opened
- reopened
- edited
- labeled
- unlabeled
- ready_for_review
- synchronize
# CodeRabbit publishes a legacy commit status named `CodeRabbit` on the
# reviewed head SHA. `status` workflows are loaded only from the default
# branch, so a PR cannot suppress or rewrite this signal path. The status is
# only a wake-up signal; the gate re-reads live reviews before any write.
status:
# pull-requests:write covers title/comment/label updates.
# contents:write is required for convertPullRequestToDraft /
# markPullRequestReadyForReview GraphQL mutations with GITHUB_TOKEN
# (otherwise: "Resource not accessible by integration"). This workflow
# never checks out PR head code.
permissions:
contents: write
pull-requests: write
jobs:
resolve-pr:
# This read-only job resolves every trusted wake-up event to a PR number
# before the write-capable job starts. The PR number is the stable identity
# used by both gate writers even when a contributor pushes a new head SHA.
if: >-
(github.event_name == 'status' &&
github.event.context == 'CodeRabbit' &&
github.event.state == 'success' &&
github.event.sender.login == 'coderabbitai[bot]' &&
github.event.sender.id == 136622811) ||
(github.event_name == 'pull_request_target' &&
((github.event.action != 'labeled' && github.event.action != 'unlabeled') ||
github.event.label.name == 'gui-screenshot-waived' ||
github.event.label.name == 'intake: hygiene-blocked' ||
github.event.label.name == 'maintainer-sponsored' ||
github.event.label.name == 'test-exception-approved' ||
github.event.label.name == 'suppression-approved' ||
github.event.label.name == 'generated-change-approved' ||
github.event.label.name == 'dependency-change-approved'))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
pull-number: ${{ steps.resolve.outputs.pull-number }}
steps:
- name: Resolve trusted gate event to PR
id: resolve
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const { owner, repo } = context.repo;
let pullNumber = context.payload.pull_request?.number ?? null;
if (context.eventName === "status") {
const sender = context.payload.sender;
const trustedCodeRabbit =
context.payload.context === "CodeRabbit" &&
context.payload.state === "success" &&
sender?.login === "coderabbitai[bot]" &&
sender?.id === 136622811;
if (!trustedCodeRabbit) {
core.info("Status producer is not the CodeRabbit GitHub App; skipping.");
return;
}
const statusSha = context.payload.sha;
// Primary authority: GitHub's commit-to-PR index. This read can
// lag a fresh head push, so a non-match is not proof of absence.
let candidates = [];
try {
const associatedPrs = await github.paginate(
github.rest.repos.listPullRequestsAssociatedWithCommit,
{ owner, repo, commit_sha: statusSha, per_page: 100 }
);
candidates = associatedPrs.filter(
candidate =>
candidate.state === "open" &&
candidate.head?.sha === statusSha
);
} catch (error) {
core.warning(
`Could not list PRs associated with commit ${statusSha}: ${error.message}`
);
}
if (candidates.length !== 1) {
// Fallback: reconcile directly against the live head SHA. The
// association index can be stale or empty for a very recent
// head (seen on PR #1441), so an empty/ambiguous index result
// must not silently drop the revalidation. Matching on the
// head SHA is the same authoritative identity the write gate
// uses, and `pulls.list` is a read — compatible with this
// job's `pull-requests: read` permission.
const priorCount = candidates.length;
try {
const openPrs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: "open",
per_page: 100
});
candidates = openPrs.filter(
pr =>
pr.state === "open" &&
pr.head?.sha === statusSha
);
core.info(
`Associated-index fallback: ${priorCount} index match(es), ${candidates.length} open PR(s) match head ${statusSha}.`
);
} catch (error) {
core.warning(
`Could not list open PRs for head-${statusSha} fallback: ${error.message}`
);
}
}
if (candidates.length !== 1) {
core.info(
`CodeRabbit status ${statusSha} maps to ${candidates.length} open current-head PRs; skipping ambiguous/stale revalidation.`
);
return;
}
pullNumber = candidates[0].number;
}
if (Number.isInteger(pullNumber)) {
core.setOutput("pull-number", String(pullNumber));
}
enforce-target:
needs: resolve-pr
if: needs.resolve-pr.outputs.pull-number != ''
runs-on: ubuntu-latest
# Job-scoped permissions replace, rather than extend, the workflow default.
permissions:
contents: write
pull-requests: write
concurrency:
# Serialize every writer by PR identity, not head SHA. An older-head run
# therefore cannot race a newer-head run that rewrites the same comment.
group: pr-gate-comment-${{ needs.resolve-pr.outputs.pull-number }}
cancel-in-progress: false
steps:
- name: Checkout trusted PR-quality scripts
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
# Source trusted scripts from an integration branch, never from the
# PR's own base commit. A stacked child PR's base is another open
# PR's head, so `base.sha` let an unpromoted commit choose which code
# runs with this job's contents/pull-requests write token.
#
# The branch is chosen, not fixed. `status` has no pull_request
# payload and runs the privileged workflow from the repository
# default branch, so it sources from that same promoted boundary. A
# `main`-targeting PR must take its scripts from `main`, or the gate
# runs a `main` workflow definition against `dev` scripts. Every
# other base, including a stacked child's, resolves to `dev`.
ref: ${{ github.event_name == 'status' && github.event.repository.default_branch || (github.event.pull_request.base.ref == 'main' && 'main' || 'dev') }}
persist-credentials: false
sparse-checkout: |
.github/scripts
MAINTAINERS.md
- name: Enforce PR target, ancestry, and description
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
RESOLVED_PULL_NUMBER: ${{ needs.resolve-pr.outputs.pull-number }}
with:
script: |
const path = require("path");
const fs = require("node:fs");
const {
collectPrQualityFailures,
authorHasPushPermission,
hasGuiOverride,
isChangedFileListTruncated,
extractReviewReadiness,
appendReviewReadinessSection,
stripReviewReadinessSection,
uncheckReviewReadinessBoxes,
REVIEW_READINESS_CLAIM_INDEX,
resetReviewReadinessSection
} = require(
path.join(process.cwd(), ".github", "scripts", "pr-quality.cjs"),
);
const {
collectDeterministicHygieneFailures,
HYGIENE_FAILURE_HINTS,
} = require(
path.join(process.cwd(), ".github", "scripts", "pr-hygiene.cjs"),
);
const {
parseGateState,
gateStateMarker,
parseState,
parseReadinessState,
defaultGateState,
migrateLegacyGateState,
completionIsStale,
readinessClaimViolations,
unresolvedFindingsClaim,
READINESS_STATE_VERSION
} = require(
path.join(
process.cwd(),
".github",
"scripts",
"pr-quality-state.cjs"
),
);
const {
GATE_MARKER,
READINESS_MARKER,
HYGIENE_MARKER,
HYGIENE_BLOCK_START,
HYGIENE_BLOCK_END,
inlineCode,
buildGateCommentBody,
extractHygieneSection,
buildFailureSections,
failureSummary,
buildStaleNotice,
buildClaimCheckNotice,
buildFindingsClaimNotice
} = require(
path.join(
process.cwd(),
".github",
"scripts",
"pr-quality-messages.cjs"
),
);
const {
parseMaintainerLogins
} = require(
path.join(
process.cwd(),
".github",
"scripts",
"pr-maintainers.cjs"
),
);
const ALLOWED_BASES = ["dev"];
const DEFAULT_BASE = "dev";
const TITLE_PREFIX = "[WRONG BRANCH] ";
const LEGACY_COMMENT_MARKER = "<!-- wrong-branch-enforcer -->";
const REVIEW_READY_LABEL = "review-ready";
const GUI_SCREENSHOT_WAIVER_LABEL = "gui-screenshot-waived";
const MAINTAINERS_FILE = "MAINTAINERS.md";
const { owner, repo } = context.repo;
const resolvedPullNumber = process.env.RESOLVED_PULL_NUMBER ?? "";
const pull_number = /^\d+$/.test(resolvedPullNumber)
? Number.parseInt(resolvedPullNumber, 10)
: Number.NaN;
// `resolve-pr` is the single authority that maps a trusted event to
// exactly one live PR. The write-capable job consumes only that
// resolved identity so its concurrency key and mutation target
// cannot diverge.
if (!Number.isSafeInteger(pull_number) || pull_number < 1) {
core.info("No pull request could be resolved for this gate event; skipping.");
return;
}
// Defense in depth: the resolver job is the primary event gate, but
// the write-capable script also rejects event classes this workflow
// never intends to mutate from.
if (!["pull_request_target", "status"].includes(context.eventName)) {
core.info(`Unsupported gate event ${context.eventName}; skipping.`);
return;
}
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number
});
const comments = await github.paginate(
github.rest.issues.listComments,
{
owner,
repo,
issue_number: pull_number,
per_page: 100
}
);
// One consolidated comment. The gate finds its own comment by the
// single GATE_MARKER; the legacy enforcer/readiness markers are
// matched only to migrate pre-consolidation PRs.
let gateComment = comments.find(
comment =>
comment.user?.login === "github-actions[bot]" &&
comment.body?.includes(GATE_MARKER)
);
let gateCommentId = gateComment?.id ?? null;
const storedGateState = parseGateState(
gateComment?.body,
message => core.warning(message)
);
// Legacy comments: the pre-consolidation two-comment model. Their
// state is merged once into the single comment, then the old
// comments are deleted.
const legacyEnforcerComment = comments.find(
comment =>
comment.user?.login === "github-actions[bot]" &&
(comment.body?.includes("<!-- pr-quality-enforcer -->") ||
comment.body?.includes(LEGACY_COMMENT_MARKER))
);
const legacyReadinessComment = comments.find(
comment =>
comment.user?.login === "github-actions[bot]" &&
comment.body?.includes(READINESS_MARKER)
);
const legacyEnforcerState = parseState(
legacyEnforcerComment?.body,
message => core.warning(message)
);
const legacyReadinessState = parseReadinessState(
legacyReadinessComment?.body,
message => core.warning(message)
);
const migratedGateState = migrateLegacyGateState(
legacyEnforcerState,
legacyReadinessState
);
let gateState = storedGateState ?? migratedGateState;
/**
* Maintainers from `MAINTAINERS.md` on the trusted default branch
* (checked out sparse by the step above). The file is the canonical
* list; mentioning these logins on the PR notifies them.
*/
function readMaintainerLogins() {
try {
const text = fs.readFileSync(
path.join(process.cwd(), MAINTAINERS_FILE),
"utf8"
);
return parseMaintainerLogins(text);
} catch (error) {
core.warning(
`Could not read ${MAINTAINERS_FILE}: ${error.message}`
);
return [];
}
}
async function setReviewReadyLabel(shouldHave, hasLabel) {
// The label is a review trigger, not a gate decision. A label
// write failure must not abort the run before the gate comment
// or the draft/ready conversion happens.
try {
if (shouldHave && !hasLabel) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pull_number,
labels: [REVIEW_READY_LABEL]
});
} else if (!shouldHave && hasLabel) {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: pull_number,
name: REVIEW_READY_LABEL
});
}
} catch (error) {
core.warning(
`Could not ${shouldHave ? "add" : "remove"} the ${inlineCode(REVIEW_READY_LABEL)} label: ${error.message}`
);
}
}
/**
* The single write to the consolidated comment. Every run rebuilds
* the full body and writes it exactly once (create-if-absent,
* update-if-present), so there is never a double-edit of the
* readiness section or a stale intermediate checkpoint body.
*/
async function upsertGateComment(state, opts) {
let body = buildGateCommentBody(state, opts).join("\n");
// The hygiene workflow writes its status into this same comment.
// Preserve whatever it left so a gate rebuild does not drop it.
const existingHygiene = extractHygieneSection(gateComment?.body);
if (existingHygiene && !body.includes("pr-hygiene-block")) {
body = body.replace(
/\s+$/,
`\n\n## Hygiene\n\n${HYGIENE_BLOCK_START}\n${HYGIENE_MARKER}\n\n${existingHygiene}\n\n${HYGIENE_BLOCK_END}`
);
}
if (gateCommentId) {
// Skip the write when the rebuilt body matches what is already
// posted. A no-op comment edit is still a mutation event to
// review bots, so updating an identical body would re-wake the
// CodeRabbit status signal that triggered this run and create
// a self-sustaining loop.
if (gateComment?.body === body) {
await migrateLegacyCommentsIfNeeded();
return;
}
await github.rest.issues.updateComment({
owner,
repo,
comment_id: gateCommentId,
body
});
gateComment.body = body;
await migrateLegacyCommentsIfNeeded();
return;
}
const created = await github.rest.issues.createComment({
owner,
repo,
issue_number: pull_number,
body
});
gateCommentId = created.data.id;
gateComment = { id: gateCommentId, body };
await migrateLegacyCommentsIfNeeded();
}
/**
* One-time migration: merge legacy state into the single comment,
* then delete the two old comments. The gate comment must already
* exist (created/updated by upsertGateComment) so its id is known
* and the legacy comments are not mistaken for it.
*/
async function migrateLegacyCommentsIfNeeded() {
const legacyIds = [
legacyEnforcerComment?.id,
legacyReadinessComment?.id
].filter(id => typeof id === "number" && id !== gateCommentId);
if (legacyIds.length === 0) return;
for (const id of legacyIds) {
try {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: id
});
} catch (error) {
core.warning(
`Could not delete legacy bot comment ${id}: ${error.message}`
);
}
}
}
async function convertToDraft() {
await github.graphql(
`
mutation($pullRequestId: ID!) {
convertPullRequestToDraft(
input: {
pullRequestId: $pullRequestId
}
) {
pullRequest {
id
isDraft
}
}
}
`,
{
pullRequestId: pr.node_id
}
);
}
async function markReadyForReview() {
await github.graphql(
`
mutation($pullRequestId: ID!) {
markPullRequestReadyForReview(
input: {
pullRequestId: $pullRequestId
}
) {
pullRequest {
id
isDraft
}
}
}
`,
{
pullRequestId: pr.node_id
}
);
}
let authorPermission = null;
let permissionLookupFailed = false;
try {
const { data: permissionData } =
await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: pr.user.login
});
authorPermission = permissionData.permission;
} catch (error) {
permissionLookupFailed = true;
core.warning(
`Could not look up collaborator permission: ${error.message}`
);
}
let behindMain = 0;
let behindBase = 0;
let aheadMain = 0;
let ancestryLookupFailed = false;
const baseAllowed = ALLOWED_BASES.includes(pr.base.ref);
// Stacked PR exception: base is another open PR's head branch (same
// head repo as this PR's base repo). Closed/missing parent stays wrong_base.
let stackedBase = false;
if (!baseAllowed) {
try {
const openPrs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: "open",
per_page: 100
});
const baseOwner =
pr.base.repo?.owner?.login ?? owner;
const baseName = pr.base.repo?.name ?? repo;
stackedBase = openPrs.some(
other =>
other.number !== pull_number &&
other.head?.ref === pr.base.ref &&
(other.base?.repo?.owner?.login ?? owner) === baseOwner &&
(other.base?.repo?.name ?? repo) === baseName
);
if (stackedBase) {
core.info(
`Base ${pr.base.ref} matches an open PR head; treating as stacked (skip wrong_base).`
);
}
} catch (error) {
core.warning(
`Could not list open PRs for stacked-base check: ${error.message}`
);
}
}
if (baseAllowed) {
const headSha = pr.head.sha;
try {
const { data: mainCompare } =
await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `main...${headSha}`
});
behindMain = mainCompare.behind_by;
aheadMain = mainCompare.ahead_by;
const { data: baseCompare } =
await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${pr.base.ref}...${headSha}`
});
behindBase = baseCompare.behind_by;
} catch (error) {
ancestryLookupFailed = true;
core.warning(
`Could not compare commits for ancestry check: ${error.message}`
);
}
}
const changedFiles = [];
const changedFilePaths = [];
let filesTruncated = true;
for (let attempt = 0; attempt < 2; attempt += 1) {
const { data: fileSnapshot } = await github.rest.pulls.get({
owner,
repo,
pull_number
});
const headShaForFiles = fileSnapshot.head?.sha ?? "";
const listedFiles = await github.paginate(
github.rest.pulls.listFiles,
{ owner, repo, pull_number, per_page: 100 }
);
const { data: fileVerify } = await github.rest.pulls.get({
owner,
repo,
pull_number
});
const headMatches = fileVerify.head?.sha === headShaForFiles;
if (!headMatches && attempt === 0) {
core.info(
"PR head moved while listing changed files; retrying once."
);
continue;
}
if (!headMatches) {
core.warning(
"PR head moved during changed-file snapshot; treating file list as truncated."
);
}
changedFiles.length = 0;
changedFiles.push(...listedFiles);
changedFilePaths.length = 0;
changedFilePaths.push(
...listedFiles.map(file => file.filename).filter(Boolean)
);
filesTruncated = isChangedFileListTruncated(
fileSnapshot.changed_files,
listedFiles.length,
headMatches
);
break;
}
let failures = collectPrQualityFailures({
baseRef: pr.base.ref,
allowedBases: ALLOWED_BASES,
title: pr.title,
body: pr.body,
behindMain,
behindBase,
aheadMain,
authorPermission,
permissionLookupFailed,
ancestryLookupFailed,
stackedBase,
// A maintainer issue comment ("not touching gui") waives the
// GUI-screenshot gate; the comments are already fetched above.
guiOverrideComments: comments,
changedFilePaths,
filesTruncated
});
// Hygiene is a separate workflow that owns the blocked label and
// the Hygiene comment section, but Ready / review-ready must not
// clear while those checks fail. Re-assess here from the same
// trusted scripts so the gate cannot race ahead of hygiene.
const labelNames = (pr.labels ?? []).map(label => label.name);
failures = [
...failures,
...collectDeterministicHygieneFailures({
files: changedFiles,
labels: labelNames,
authorHasPushPermission:
!permissionLookupFailed &&
authorHasPushPermission(authorPermission),
}),
];
// A maintainer issue comment saying the change does not touch
// the GUI waives the screenshot gate. The flag is what tells the
// author the screenshot is not required, even though the failure
// itself is gone from `failures`.
const screenshotWaiverLabelPresent = (pr.labels ?? []).some(
label => label.name === GUI_SCREENSHOT_WAIVER_LABEL
);
const maintainerLogins = new Set(
readMaintainerLogins().map(login => login.toLowerCase())
);
let screenshotWaiverLabelActorLogin = null;
if (screenshotWaiverLabelPresent) {
try {
const issueEvents = await github.paginate(
github.rest.issues.listEvents,
{
owner,
repo,
issue_number: pull_number,
per_page: 100
}
);
const waiverEvents = issueEvents
.filter(
event =>
(event.event === "labeled" || event.event === "unlabeled") &&
event.label?.name === GUI_SCREENSHOT_WAIVER_LABEL
)
.sort((left, right) => {
const leftTime = Date.parse(left.created_at ?? "") || 0;
const rightTime = Date.parse(right.created_at ?? "") || 0;
if (leftTime !== rightTime) return leftTime - rightTime;
return Number(left.id ?? 0) - Number(right.id ?? 0);
});
const latestWaiverEvent = waiverEvents.at(-1);
if (latestWaiverEvent?.event === "labeled") {
screenshotWaiverLabelActorLogin =
latestWaiverEvent.actor?.login ?? null;
}
} catch (error) {
core.warning(
`Could not resolve ${GUI_SCREENSHOT_WAIVER_LABEL} label provenance: ${error.message}`
);
}
}
const screenshotWaivedByLabel =
screenshotWaiverLabelPresent &&
typeof screenshotWaiverLabelActorLogin === "string" &&
maintainerLogins.has(screenshotWaiverLabelActorLogin.toLowerCase());
if (screenshotWaiverLabelPresent && !screenshotWaivedByLabel) {
core.info(
`${screenshotWaiverLabelActorLogin ?? "unknown label actor"} is not in MAINTAINERS.md; ignoring ${GUI_SCREENSHOT_WAIVER_LABEL}.`
);
}
if (screenshotWaivedByLabel) {
failures = failures.filter(
failure => failure.code !== "missing_ui_screenshot"
);
}
const screenshotWaived =
screenshotWaivedByLabel || hasGuiOverride({ comments });
const screenshotWaiverNotice = screenshotWaived
? (screenshotWaivedByLabel
? `UI screenshot waived by the ${inlineCode(GUI_SCREENSHOT_WAIVER_LABEL)} label.`
: "UI screenshot waived by a maintainer comment.")
: null;
// The readiness gate applies to contributors (no push permission).
// Maintainers keep the failure-only contract: draft while quality
// gates fail, ready again once they clear. A failed permission
// lookup fails closed — the PR is treated as a contributor PR.
const authorIsMaintainer =
!permissionLookupFailed && authorHasPushPermission(authorPermission);
const checklistRequired = !authorIsMaintainer;
// A confirmed maintainer does not need the bot's checklist: retire
// the injected section from the body so it stops rendering as a
// gate on later runs.
let readiness = extractReviewReadiness(pr.body);
if (!checklistRequired && readiness.present) {
const strippedBody = stripReviewReadinessSection(pr.body ?? "");
if (strippedBody !== pr.body) {
await github.rest.pulls.update({
owner,
repo,
pull_number,
body: strippedBody
});
}
readiness = extractReviewReadiness(strippedBody);
}
// The tickable checklist lives in the PR body, because only the PR
// author can edit it. Inject it once; the HTML markers make the
// injection idempotent, so the `edited` event this write triggers
// cannot churn the body on every run.
if (checklistRequired && !readiness.present) {
const injectedBody = appendReviewReadinessSection(pr.body ?? "");
await github.rest.pulls.update({
owner,
repo,
pull_number,
body: injectedBody
});
readiness = extractReviewReadiness(injectedBody);
}
let checklistComplete = readiness.present && readiness.complete;
// A completed checklist is an attestation about a specific head
// (see `completionIsStale`). When it is stale the gate resets the
// boxes and the notification state, re-drafts, and tells the
// author to re-test and re-tick against the latest code.
// `status` events carry no `pull_request.head.sha`, so the
// fallback to the live head would let a completed checklist with
// no recorded completion head pass as if it attested the current
// head. A status-triggered run must not promote readiness: pass
// the live head only when the event actually delivered it.
const eventHeadSha =
context.payload.pull_request?.head?.sha ??
(context.eventName === "status"
? ""
: pr.head.sha);
const completionHeadSha =
gateState.completedAtHeadSha ?? null;
const headDrifted = completionIsStale({
checklistRequired,
checklistComplete,
readinessPresent: readiness.present,
completionHeadSha,
eventHeadSha,
liveHeadSha: pr.head.sha,
eventAction: context.payload.action
});
let readinessStateOverride = null;
let headDriftNotice = [];
let revalidationNotice = [];
if (headDrifted) {
// Re-fetch the PR so an author edit that landed while this job
// was reading cannot be clobbered by the reset.
const { data: freshPr } = await github.rest.pulls.get({
owner,
repo,
pull_number
});
const freshReadiness = extractReviewReadiness(
freshPr.body ?? ""
);
// Reset only the checklist/notification state. The bot's draft
// ownership and title-prefix ownership survive the reset so a
// wrong-base-drafted PR that was retargeted keeps its restore
// path and its prefix ownership.
readinessStateOverride = {
...defaultGateState(),
active: gateState.active,
autoDraftedByBot: gateState.autoDraftedByBot,
titlePrefixedByBot: gateState.titlePrefixedByBot
};
headDriftNotice = buildStaleNotice({
completionHeadSha,
liveHeadSha: freshPr.head.sha,
eventAction: context.payload.action
});
if (freshReadiness.present && freshReadiness.complete) {
const resetBody = resetReviewReadinessSection(
freshPr.body ?? ""
);
if (resetBody !== freshPr.body) {
await github.rest.pulls.update({
owner,
repo,
pull_number,
body: resetBody
});
}
readiness = extractReviewReadiness(resetBody);
} else {
readiness = freshReadiness;
}
checklistComplete = readiness.present && readiness.complete;
}
// The bot verifies the checklist claims it can check itself. The
// local-CI box is an author attestation only — fork contributors
// cannot start repository CI (a maintainer has to) — so the gate
// never disproves it; head-drift still resets every box after a
// new push. The latest-dev box only counts while the head is at
// most READINESS_LATEST_DEV_BEHIND_MAX commits behind the base;
// the findings box only counts while every Codex/CodeRabbit
// review thread on the PR is resolved. A disproved claim unchecks
// that box and keeps the PR a draft, exactly like a head-drift
// reset.
let claimViolations = [];
let claimNotice = [];
if (
checklistRequired &&
checklistComplete &&
!headDrifted &&
failures.length === 0
) {
claimViolations = readinessClaimViolations({
behindBase,
behindUnknown: ancestryLookupFailed
});
// The findings claim reads the review threads via GraphQL. Only
// threads authored by the review bots count; `isResolved` must be
// explicitly true, so a missing or unreadable thread fails closed.
// CodeRabbit additionally reports some findings only in its
// review body (outside the diff range); `pulls.listReviews`
// supplies those as a supplement. A review listing failure fails
// closed the same way as an unreadable thread list.
let findingsClaim = null;
let findingsUnverifiable = false;
try {
// Paginate the review threads: a busy PR can carry more than
// 100 threads (CodeRabbit posts many reviews), and a truncated
// read would silently miss unresolved bot threads — a fail-open
// gap in a fail-closed check.
const allThreadNodes = [];
let threadCursor = null;
let threadPage = null;
let reviewThreadsPage = null;
do {
threadPage = await github.graphql(
`
query($owner: String!, $repo: String!, $number: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
reviewThreads(first: 100, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
isResolved
comments(first: 1) {
nodes {
author { login }
}
}
}
}
}
}
}
`,
{
owner,
repo,
number: pull_number,
cursor: threadCursor
}
);
reviewThreadsPage = threadPage?.repository?.pullRequest?.reviewThreads;
allThreadNodes.push(...(reviewThreadsPage?.nodes ?? []));
threadCursor = reviewThreadsPage?.pageInfo?.endCursor ?? null;
} while (
reviewThreadsPage?.pageInfo?.hasNextPage === true &&
threadCursor
);
const reviewsData = await github.paginate(
github.rest.pulls.listReviews,
{
owner,
repo,
pull_number,
per_page: 100
}
);
findingsClaim = unresolvedFindingsClaim({
threads: allThreadNodes.map(node => ({
isResolved: node.isResolved,
author: node.comments?.nodes?.[0]?.author ?? null
})),
reviews: reviewsData,
liveHeadSha: pr.head.sha
});
} catch (error) {
core.warning(
`Could not list review threads for the readiness claim check: ${error.message}`
);
// Fail closed: an attestation must not ride on missing
// evidence, exactly like unknown CI or behind counts.
findingsUnverifiable = true;
findingsClaim = {
code: "review_findings",
unresolved: 0,
byBot: {}
};
}
if (findingsClaim?.code) {
claimViolations.push(findingsClaim.code);
}
if (claimViolations.length > 0) {
const { data: freshPr } = await github.rest.pulls.get({
owner,
repo,
pull_number
});
const freshReadiness = extractReviewReadiness(
freshPr.body ?? ""
);
readinessStateOverride = {
...defaultGateState(),
active: gateState.active,
autoDraftedByBot: gateState.autoDraftedByBot,
titlePrefixedByBot: gateState.titlePrefixedByBot
};
claimNotice = [
...(claimViolations.includes("review_findings")
? findingsUnverifiable
? [
"The Codex/CodeRabbit findings claim could not be verified; the **Codex/CodeRabbit findings** box has been unticked. The PR stays a draft until review threads are readable again."
]
: buildFindingsClaimNotice(findingsClaim.byBot)
: []),
...buildClaimCheckNotice(
claimViolations.filter(
code => code !== "review_findings"
),
freshPr.head.sha
)
];
if (freshReadiness.present) {
const uncheckedBody = uncheckReviewReadinessBoxes(
freshPr.body ?? "",
claimViolations.map(
code => REVIEW_READINESS_CLAIM_INDEX[code]
)
);
if (uncheckedBody !== freshPr.body) {
await github.rest.pulls.update({
owner,
repo,
pull_number,
body: uncheckedBody
});
}
readiness = extractReviewReadiness(uncheckedBody);
} else {
readiness = freshReadiness;
}
checklistComplete = readiness.present && readiness.complete;
}
}
// A contributor PR stays a draft while the checklist is open, even
// when every quality gate already passes.
const mustDraft =
failures.length > 0 || (checklistRequired && !checklistComplete);
// Which reset notice (head drift vs claim check) accompanies the
// draft path; only one can be active because the claim check is
// skipped when the head drifted.
revalidationNotice = headDrifted
? headDriftNotice
: claimNotice;
// Assemble the "What to do" action lines for the consolidated
// comment. Only the applicable actions render.
function buildActions() {
const actions = [];
if (failures.some(failure => failure.code === "wrong_base")) {
actions.push(
`Retarget this PR to ${inlineCode(DEFAULT_BASE)} — all contributions go to ${inlineCode(DEFAULT_BASE)}.`
);
}
if (failures.some(failure => failure.code === "wrong_ancestry")) {
actions.push(
`Rebase onto the current ${inlineCode(DEFAULT_BASE)} branch instead of opening from ${inlineCode("main")}.`
);
}
if (failures.some(failure => failure.code === "bad_description")) {
actions.push(
"Add a real **Summary** and **Test plan** to the PR description."
);
}
if (failures.some(failure => failure.code === "missing_ui_screenshot")) {
actions.push(
"Add a screenshot of the UI change to the PR description."
);
}
for (const failure of failures) {
const hint = HYGIENE_FAILURE_HINTS[failure.code];
if (!hint) continue;
const paths = failure.paths?.length
? ` Paths: ${failure.paths.map(p => inlineCode(p)).join(", ")}.`
: "";
actions.push(`Fix **${failure.code}** — ${hint}${paths}`);
}
if (checklistRequired && !checklistComplete) {
actions.push(
`Tick all four boxes in the PR description once you're done (currently ${readiness.checked}/${readiness.total}).`
);
}
if (revalidationNotice.length > 0) {
actions.push(...revalidationNotice);
}
return actions;
}
// The `review-ready` label marks the ready moment for humans and
// bots. It is not a CodeRabbit auto-review filter: a positive
// `labels:` entry in `.coderabbit.yaml` would restrict ALL reviews
// to labeled PRs (maintainer PRs never carry this label), so the
// label is kept as a visible status marker only.
const readyMoment =
checklistRequired && checklistComplete && failures.length === 0;
const reviewReadyDesired = readyMoment;
const hasReviewReadyLabel = (pr.labels ?? []).some(
label => label.name === REVIEW_READY_LABEL
);
const reviewReadyChanged = hasReviewReadyLabel !== reviewReadyDesired;
if (reviewReadyChanged) {
await setReviewReadyLabel(reviewReadyDesired, hasReviewReadyLabel);
}
gateState.reviewReadyLabeled = reviewReadyDesired;
if (mustDraft) {
let draftConverted = false;
const draftState = readinessStateOverride ?? { ...gateState };
if (checklistRequired && checklistComplete) {
// The attestation covers this head even while another quality
// gate keeps the draft: bind it now, because the failure path
// below returns before the completion block that records it.
draftState.completedAtHeadSha = pr.head.sha;
draftState.version = 1;
}
const state = { ...draftState, active: true };
const hasWrongBase = failures.some(
failure => failure.code === "wrong_base"
);
const willPrefixTitle =
hasWrongBase && !pr.title.startsWith(TITLE_PREFIX);
const shouldStripTitlePrefix =
!hasWrongBase &&
state.titlePrefixedByBot &&
pr.title.startsWith(TITLE_PREFIX);
// Claim title ownership before adding a prefix. Do not clear
// ownership until strip succeeds — a failed update must retry.
if (willPrefixTitle) {
state.titlePrefixedByBot = true;
}
// A stale bot-owned prefix comes off once, whichever path owns
// the draft: the branch is fixed even when the checklist is not.
if (shouldStripTitlePrefix) {
await github.rest.pulls.update({
owner,
repo,
pull_number,
title: pr.title.slice(TITLE_PREFIX.length)
});
state.titlePrefixedByBot = false;
}
if (failures.length > 0) {
let draftConversionFailed = false;
// One-line reason for each failure, used in the status line.
const failureStatusReason = failures
.map(failure => {
if (failure.code === "wrong_base") {
return `wrong target branch (${pr.base.ref}); retarget to ${inlineCode(DEFAULT_BASE)}.`;
}
if (failure.code === "wrong_ancestry") {
return "wrong branch ancestry; rebase onto the latest dev.";
}
if (failure.code === "bad_description") {
return `PR description needs work (${failure.reason}).`;
}
if (failure.code === "missing_ui_screenshot") {
return "UI screenshot required.";
}
if (HYGIENE_FAILURE_HINTS[failure.code]) {
return `hygiene: ${failure.code}.`;
}
return failure.code;
})
.join(" ");
// Shared notices for the failure comment: revalidation reason,
// the waiver flag, and the prefix notice when the bot owns it.
const failureNotices = [
...revalidationNotice,
...(screenshotWaiverNotice ? [screenshotWaiverNotice] : []),
...(hasWrongBase && state.titlePrefixedByBot
? [`Its title has been prefixed with ${inlineCode(TITLE_PREFIX.trim())}.`]
: [])
];
const draftComment = (notices) =>
upsertGateComment(state, {
status: "DRAFT",
statusReason: failureStatusReason,
actions: buildActions(),
readiness,
checklistRequired,
notices
});
// Apply the bot-owned title prefix so the PR itself carries a
// durable signal of the wrong base (claim ownership before the
// write; a failed write keeps ownership for the next retry).
if (willPrefixTitle) {
await github.rest.pulls.update({
owner,
repo,
pull_number,
title: `${TITLE_PREFIX}${pr.title}`
});
}
const wantsDraftConversion = !pr.draft;
if (wantsDraftConversion) {
// Persist the ownership claim BEFORE the mutation so a
// successful convert followed by a failed comment write
// still leaves the bot-created draft owned and restorable.
state.autoDraftedByBot = true;
await draftComment([
...failureNotices,
"This pull request is being kept as a draft automatically. Once every issue above is resolved, it will be marked ready for review again.",
...(checklistRequired && !checklistComplete
? [
`@${pr.user.login} Tick the boxes once your local CI is green, your branch is on the latest ${inlineCode(DEFAULT_BASE)} commit, and every correct Codex and CodeRabbit finding is resolved.`
]
: [])
]);
try {
await convertToDraft();
draftConverted = true;
} catch (error) {
draftConversionFailed = true;
state.autoDraftedByBot = false;
core.warning(
`Could not convert pull request to draft: ${error.message}`
);
// Reflect the failed conversion in the persisted state.
await draftComment([
...failureNotices,
"Automatic draft conversion failed (token cannot change draft status). Please convert this pull request to a draft manually. The required `enforce-target` check will keep failing until every issue above is resolved."
]);
}
} else {
await draftComment([
...failureNotices,
"This pull request was already a draft. Its draft status will be preserved after every issue above is resolved.",
...(checklistRequired && !checklistComplete
? [
`@${pr.user.login} Tick the boxes once your local CI is green, your branch is on the latest ${inlineCode(DEFAULT_BASE)} commit, and every correct Codex and CodeRabbit finding is resolved.`
]
: [])
]);
}
core.setFailed(
`PR quality gate failed: ${failureSummary(failures, { pr })}`
);
return;
}
// No quality failure; the draft is owed by the open checklist.
if (pr.draft) {
// Already a draft: update the gate comment with the open
// checklist status and no conversion needed.
await upsertGateComment(state, {
status: "DRAFT",
statusReason: checklistRequired
? `review readiness checklist open (${readiness.checked}/${readiness.total} boxes ticked).`
: "PR is kept in draft.",
actions: buildActions(),
readiness,
checklistRequired,
notices: [
...revalidationNotice,
...(screenshotWaiverNotice ? [screenshotWaiverNotice] : []),
"This PR stays in draft until every box above is ticked."
]
});
} else if (!draftConverted) {
// Persist the ownership checkpoint BEFORE the mutation so a
// successful convert followed by a failed comment write still
// leaves the bot-created draft owned and restorable. The
// failure path above uses the same ordering via draftComment.
state.autoDraftedByBot = true;
await upsertGateComment(state, {
status: "DRAFT",
statusReason: checklistRequired
? `review readiness checklist open (${readiness.checked}/${readiness.total} boxes ticked).`
: "PR is kept in draft.",
actions: buildActions(),
readiness,
checklistRequired,
notices: [
...revalidationNotice,
...(screenshotWaiverNotice ? [screenshotWaiverNotice] : []),
"This PR stays in draft until every box above is ticked."
]
});
try {
await convertToDraft();
draftConverted = true;
} catch (error) {
state.autoDraftedByBot = false;
core.warning(
`Could not convert pull request to draft: ${error.message}`
);
core.setFailed(
"PR quality gate failed: could not convert the pull request to draft while the review readiness checklist is open."
);
}
}
// A failed conversion must still surface in the persisted state:
// the checkpoint above claimed ownership, so a failure rewrites
// it to release ownership and tell the author what to do.
if (!draftConverted && !pr.draft) {
state.autoDraftedByBot = false;
await upsertGateComment(state, {
status: "DRAFT",
statusReason: checklistRequired
? `review readiness checklist open (${readiness.checked}/${readiness.total} boxes ticked).`
: "PR is kept in draft.",
actions: buildActions(),
readiness,
checklistRequired,
notices: [
...revalidationNotice,
...(screenshotWaiverNotice ? [screenshotWaiverNotice] : []),
"Automatic draft conversion failed. Please convert this pull request to a draft manually until every box above is ticked."
]
});
}
return;
}
// Ready path: strip a stale title prefix, mark ready, clear state.
if (
gateState.titlePrefixedByBot &&
pr.title.startsWith(TITLE_PREFIX)
) {
await github.rest.pulls.update({
owner,
repo,
pull_number,
title: pr.title.slice(TITLE_PREFIX.length)
});
gateState.titlePrefixedByBot = false;
}
let readyConversionFailed = false;
let readyConverted = false;
const shouldMarkReady =
(gateState.active && gateState.autoDraftedByBot) ||
(checklistRequired && checklistComplete);
if (shouldMarkReady && pr.draft) {
try {
await markReadyForReview();
readyConverted = true;
} catch (error) {
readyConversionFailed = true;
core.warning(
`Could not mark pull request ready for review: ${error.message}`
);
}
}
const readyState = {
...gateState,
active: readyConversionFailed ? true : false
};
if (checklistRequired && checklistComplete) {
const maintainers = readMaintainerLogins().filter(
login => login !== pr.user.login
);
let notified = false;
if (!readyState.maintainersPinged && maintainers.length > 0) {
readyState.maintainersPinged = true;
notified = true;
}
readyState.completedAtHeadSha = pr.head.sha;
readyState.version = 1;
const notices = [
screenshotWaiverNotice,
readyConversionFailed
? "Automatic ready-for-review conversion failed; please mark the pull request ready manually if it is still a draft."
: readyConverted
? "This pull request has been marked Ready for Review."
: "This pull request is already Ready for Review.",
readyMoment
? `The ${inlineCode(REVIEW_READY_LABEL)} label marks this PR as ready; review automation runs independently.`
: "",
notified && maintainers.length > 0
? `Maintainers notified: ${maintainers
.map(login => `@${login}`)
.join(" ")}`
: maintainers.length > 0
? `Maintainers: ${maintainers
.map(login => `@${login}`)
.join(" ")}`
: "Maintainers will be notified."
].filter(Boolean);
await upsertGateComment(readyState, {
status: "READY",
statusReason: "all PR quality gates passed; the review readiness checklist is complete.",
actions: [],
readiness,
checklistRequired,
notices
});
return;
}
// Maintainer PR with no checklist: the comment is the single status
// surface but there is nothing to tick; only render it if the
// author is a maintainer and no checklist is required.
if (!checklistRequired) {
// A maintainer PR the gate drafted (`autoDraftedByBot`) must be
// restored when its failures clear. A maintainer PR that was
// already a draft while it failed a gate still carries an active
// gate comment (`active: true` with stale DRAFT actions) that
// must be cleared to READY even though the bot never converted
// it — otherwise the old comment keeps telling the author to fix
// already-passed gates.
if (gateState.autoDraftedByBot && pr.draft) {
let recoveryFailed = false;
if (!readyConverted && !readyConversionFailed) {
try {
await markReadyForReview();
} catch (error) {
recoveryFailed = true;
core.warning(
`Could not mark pull request ready for review: ${error.message}`
);
}
} else {
// The ready path already handled the mutation: either it
// converted (nothing left to do) or it failed and this run
// must not attempt a second mutation on the stale draft.
recoveryFailed = readyConversionFailed;
}
const recoveredState = {
...gateState,
autoDraftedByBot: recoveryFailed
};
await upsertGateComment(recoveredState, {
status: "READY",
statusReason: recoveryFailed
? "ready-for-review conversion failed; will retry on the next run."
: "this PR is ready for review.",
actions: [],
readiness,
checklistRequired,
notices: screenshotWaiverNotice ? [screenshotWaiverNotice] : []
});
return;
}
if (gateState.active) {
await upsertGateComment(
{ ...gateState, active: false, autoDraftedByBot: false },
{
status: "READY",
statusReason: "all PR quality gates passed.",
actions: [],
readiness,
checklistRequired,
notices: screenshotWaiverNotice ? [screenshotWaiverNotice] : []
}
);
return;
}
core.info(
"All PR quality gates passed and there is no active bot state."
);
return;
}