Skip to content

Label PR review state #7371

Label PR review state

Label PR review state #7371

name: Label PR review state
on:
schedule:
- cron: "0 * * * *" # hourly fallback
workflow_dispatch:
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
cancel-in-progress: false
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-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 = '<!-- zoo-code-pr-review-process -->';
const codeRabbitLabelMarkerPrefix = '<!-- coderabbit-review-label:';
const codeRabbitLogin = 'coderabbitai[bot]';
const codeRabbitActiveLabel = 'coderabbit-review-active';
const reviewGateName = 'Zoo Code / PR review gate';
const reconciliationCheckName = 'Zoo Code / reconcile PR review state';
// When triggered by a single PR event, only reconcile that PR.
// The hourly schedule and workflow_dispatch reconcile all open PRs.
let prs;
let eventPrNumbers = [];
if (context.payload.pull_request?.number) {
eventPrNumbers = [context.payload.pull_request.number];
} else if (context.eventName === 'workflow_dispatch') {
eventPrNumbers = [Number(context.payload.inputs.pull_request_number)];
} else if (context.payload.workflow_run?.pull_requests) {
eventPrNumbers = context.payload.workflow_run.pull_requests.map(pr => pr.number);
}
if (eventPrNumbers.length > 0) {
prs = await Promise.all(eventPrNumbers.map(async pull_number => {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
return pr;
}));
prs = prs.filter(pr => pr.state === 'open');
} else if (context.eventName === 'workflow_run') {
prs = [];
} else {
prs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});
}
// pull_request_review runs originating from forks can still receive a read-only
// token. pull_request_target, schedule, and workflow_dispatch runs execute in the
// base repository context and can safely mutate metadata without trusting PR code.
// See: https://docs.github.com/en/actions/concepts/security/github_token
const isReadOnlyRun = context.eventName === 'pull_request_review' &&
Boolean(context.payload.pull_request) &&
context.payload.pull_request.head?.repo?.full_name !==
context.payload.pull_request.base?.repo?.full_name;
function isForkPR(pr) {
return pr.head?.repo?.full_name && pr.head.repo.full_name !== pr.base?.repo?.full_name;
}
if (!isReadOnlyRun) {
for (const label of labelDefinitions) {
try {
await github.rest.issues.getLabel({ owner, repo, name: label.name });
} catch (error) {
if (error.status !== 404) throw error;
await github.rest.issues.createLabel({ owner, repo, ...label });
}
}
}
// Strips stateLabels from a PR, optionally keeping one.
// Also removes stale-awaiting-author when not keeping awaiting-author.
// Only skipped when this run's own token is read-only (see isReadOnlyRun) —
// schedule/workflow_dispatch runs reconcile fork PRs normally.
async function reconcileLabels(pr, desiredLabel) {
if (isReadOnlyRun && isForkPR(pr)) {
core.info(`PR #${pr.number}: fork PR on a read-only run — skipping label mutation`);
return;
}
const currentLabels = new Set(pr.labels.map(l => l.name));
const labelErrors = [];
for (const label of stateLabels) {
if (label !== desiredLabel && currentLabels.has(label)) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: pr.number, name: label,
});
currentLabels.delete(label);
pr.labels = pr.labels.filter(current => current.name !== label);
} catch (err) {
if (err.status === 404) {
currentLabels.delete(label);
pr.labels = pr.labels.filter(current => current.name !== label);
} else {
labelErrors.push(err);
}
}
}
}
if (desiredLabel && !currentLabels.has(desiredLabel)) {
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: pr.number, labels: [desiredLabel],
});
currentLabels.add(desiredLabel);
pr.labels.push({ name: desiredLabel });
} catch (err) {
labelErrors.push(err);
}
}
if (desiredLabel !== 'awaiting-author' && currentLabels.has('stale-awaiting-author')) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: pr.number, name: 'stale-awaiting-author',
});
currentLabels.delete('stale-awaiting-author');
pr.labels = pr.labels.filter(current => current.name !== 'stale-awaiting-author');
} catch (err) {
if (err.status === 404) {
currentLabels.delete('stale-awaiting-author');
pr.labels = pr.labels.filter(current => current.name !== 'stale-awaiting-author');
} else {
labelErrors.push(err);
}
}
}
if (labelErrors.length > 0) {
throw new Error(`Could not reconcile labels: ${labelErrors.map(err => err.message).join('; ')}`);
}
}
async function findReviewGuide(pr) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number, per_page: 100,
});
return comments.find(comment =>
comment.user?.login === 'github-actions[bot]' && comment.body?.includes(guideMarker)
);
}
async function setCodeRabbitReviewActive(pr, enabled, recycle = false) {
if (isReadOnlyRun && isForkPR(pr)) return;
const hasLabel = pr.labels.some(label => label.name === codeRabbitActiveLabel);
if (enabled && hasLabel && recycle) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: pr.number, name: codeRabbitActiveLabel,
});
} catch (error) {
if (error.status !== 404) throw error;
}
pr.labels = pr.labels.filter(label => label.name !== codeRabbitActiveLabel);
}
if (enabled && (!hasLabel || recycle)) {
let addError = null;
for (let attempt = 1; attempt <= 2; attempt++) {
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: pr.number, labels: [codeRabbitActiveLabel],
});
if (!pr.labels.some(label => label.name === codeRabbitActiveLabel)) {
pr.labels.push({ name: codeRabbitActiveLabel });
}
delete pr.codeRabbitActivationUncertain;
return;
} catch (error) {
addError = error;
if (attempt === 1) {
core.warning(`PR #${pr.number}: retrying CodeRabbit activation label: ${error.message}`);
}
}
}
pr.codeRabbitActivationUncertain = true;
try {
const { data: issue } = await github.rest.issues.get({
owner, repo, issue_number: pr.number,
});
pr.labels = issue.labels.map(label =>
typeof label === 'string' ? { name: label } : label
);
if (pr.labels.some(label => label.name === codeRabbitActiveLabel)) {
delete pr.codeRabbitActivationUncertain;
return;
}
} catch (verificationError) {
core.warning(
`PR #${pr.number}: could not verify CodeRabbit activation label: ${verificationError.message}`
);
}
throw addError;
} else if (!enabled && (hasLabel || pr.codeRabbitActivationUncertain)) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: pr.number, name: codeRabbitActiveLabel,
});
} catch (error) {
if (error.status !== 404) throw error;
}
pr.labels = pr.labels.filter(label => label.name !== codeRabbitActiveLabel);
delete pr.codeRabbitActivationUncertain;
}
}
function codeRabbitLabelHead(comment) {
const markerPattern = new RegExp(`${codeRabbitLabelMarkerPrefix}([a-f0-9]{40}) -->`);
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.',
'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.
// Fails closed if the endpoint is unavailable.
let requiredChecks = null;
try {
const rules = await github.paginate(
'GET /repos/{owner}/{repo}/rules/branches/{branch}',
{ owner, repo, branch: 'main', per_page: 100 },
);
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(', ')}`
);
}
} catch (err) {
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 {
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 = 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 = 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,
})
: [];
// listForRef returns every check run ever recorded on the ref, including
// stale superseded ones (e.g. a failed run later re-run green). Branch
// protection and the PR UI only consider the latest run per check name, so
// reduce to that before evaluating — otherwise a single stale failure makes
// ciFailed true forever and state labels never come back. See issue #884.
//
// Unlike listReviews (which documents oldest-first order), listForRef's
// ordering is unspecified, so we pick the latest by run.id — GitHub assigns
// monotonically increasing IDs, and id is never null (a freshly re-queued
// 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);
}
}
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);
}
}
// 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} 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 ciFailed = relevantRuns.some(
run => run.status === 'completed' &&
run.conclusion !== 'success' &&
run.conclusion !== 'skipped' &&
run.conclusion !== 'neutral',
) || 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;
}
// CI is passing. Now determine review state.
const reviews = await github.paginate(github.rest.pulls.listReviews, {
owner, repo, pull_number: pr.number, per_page: 100,
});
// Reduce to each reviewer's latest meaningful state.
// Reviews are returned oldest-first, so last-write-wins yields the latest state.
// COMMENTED and DISMISSED are treated as neutral — they do not
// block the PR or indicate the author needs to act.
const latest = new Map();
for (const r of reviews) {
const reviewer = r.user.login.toLowerCase();
if (r.state === 'DISMISSED') {
latest.delete(reviewer);
} else if (r.state !== 'COMMENTED') {
latest.set(reviewer, r);
}
}
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 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;
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 {
desiredLabel = null;
phase = 'approved';
}
core.info(
`PR #${pr.number}: CI passing, reviews=${latest.size}, ` +
`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) {
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;
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}`);
}
}
if (failures.length > 0) {
core.setFailed(`Failed to reconcile ${failures.length} PR(s): ${failures.join('; ')}`);
}