Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 47 additions & 19 deletions .github/workflows/label-pr-review-state.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ name: Label PR review state
on:
schedule:
- cron: "0 * * * *" # hourly fallback
push:
branches: [main]
workflow_dispatch:
inputs:
pull_request_number:
Expand All @@ -27,7 +29,7 @@ permissions:
# This privileged workflow only reads PR/check metadata and writes issue labels,
# comments, and commit statuses. All unspecified permissions, including contents,
# are none; no fork code or configuration is checked out or executed.
pull-requests: read
pull-requests: write
issues: write
checks: read
statuses: write
Expand Down Expand Up @@ -83,6 +85,7 @@ jobs:
const guideMarker = '<!-- zoo-code-pr-review-process -->';
const codeRabbitLabelMarkerPrefix = '<!-- coderabbit-review-label:';
const codeRabbitLogin = 'coderabbitai[bot]';
const codeRabbitLogins = new Set([codeRabbitLogin, 'coderabbitai']);
const codeRabbitActiveLabel = 'coderabbit-review-active';
const reviewGateName = 'Zoo Code / PR review gate';
const reconciliationCheckName = 'Zoo Code / reconcile PR review state';
Expand All @@ -95,7 +98,7 @@ jobs:
}

// When triggered by a single PR event, only reconcile that PR.
// The hourly schedule and workflow_dispatch reconcile all open PRs.
// Main-branch pushes, the hourly schedule, and workflow_dispatch reconcile all open PRs.
let prs;
let eventPrNumbers = [];
if (context.payload.pull_request?.number) {
Expand Down Expand Up @@ -180,6 +183,19 @@ jobs:
}
const currentLabels = new Set(pr.labels.map(l => l.name));
const labelErrors = [];
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) {
const error = new Error(`Could not add desired label: ${err.message}`);
error.preserveStateLabels = true;
throw error;
}
}
for (const label of stateLabels) {
if (label !== desiredLabel && currentLabels.has(label)) {
try {
Expand All @@ -198,17 +214,6 @@ jobs:
}
}
}
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({
Expand Down Expand Up @@ -328,6 +333,7 @@ jobs:
conflict: 'Resolve the merge conflicts. The review sequence resumes after the branch is mergeable.',
'ci-pending': 'Wait for required CI checks; awaiting-maintainer requires CI and automated review completion.',
'ci-failed': 'Fix the failing required CI checks; awaiting-maintainer requires CI and automated review completion.',
'mergeability-pending': 'Wait for GitHub to finish calculating mergeability.',
'configuration-error': 'Repository rules must not require this advisory workflow\'s own gate or reconciliation job.',
'coderabbit-changes': 'Address automated review findings and push fixes.',
coderabbit: 'Required CI passed. Waiting for automated review of the latest commit.',
Expand Down Expand Up @@ -626,10 +632,11 @@ jobs:
const latest = new Map();
for (const r of reviews) {
const reviewer = r.user.login.toLowerCase();
const reviewerKey = codeRabbitLogins.has(reviewer) ? codeRabbitLogin : reviewer;
if (r.state === 'DISMISSED') {
latest.delete(reviewer);
latest.delete(reviewerKey);
} else if (r.state !== 'COMMENTED') {
latest.set(reviewer, r);
latest.set(reviewerKey, r);
}
}

Expand All @@ -641,6 +648,7 @@ jobs:
for (const review of latest.values()) {
if (review.commit_id !== pr.head.sha ||
review.user?.type === 'Bot' ||
codeRabbitLogins.has(review.user?.login.toLowerCase()) ||
review.user?.login.toLowerCase() === pr.user?.login.toLowerCase()) {
continue;
}
Expand Down Expand Up @@ -700,6 +708,24 @@ jobs:
phase = 'approved';
}

if (desiredLabel === 'awaiting-maintainer') {
const { data: latestPrDetail } = await github.rest.pulls.get({
owner, repo, pull_number: pr.number,
});
if (latestPrDetail.mergeable === false && latestPrDetail.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;
}
if (latestPrDetail.mergeable === null || latestPrDetail.mergeable_state === 'unknown') {
desiredLabel = null;
phase = 'mergeability-pending';
}
}

core.info(
`PR #${pr.number}: CI passing, reviews=${latest.size}, ` +
`coderabbit=${freshCodeRabbitReview?.state ?? (automatedAuthor ? 'optional' : 'pending')}, ` +
Expand Down Expand Up @@ -739,10 +765,12 @@ jobs:
} catch (cleanupError) {
metadataErrors.push(cleanupError);
}
try {
await reconcileLabels(pr, null);
} catch (cleanupError) {
metadataErrors.push(cleanupError);
if (!error.preserveStateLabels) {
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}` : ''})`
Expand Down
106 changes: 96 additions & 10 deletions src/services/__tests__/pr-review-state-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface HarnessOptions {
conflict?: boolean
mergeable?: boolean | null
mergeableState?: string
mergeabilitySequence?: Array<{ mergeable: boolean | null; mergeableState: string }>
fork?: boolean
eventName?: string
issueCommentActor?: string
Expand Down Expand Up @@ -246,7 +247,15 @@ async function runWorkflow(options: HarnessOptions = {}) {
}
return [pr]
})
const getPullRequest = vi.fn(async () => ({ data: pr }))
let mergeabilityIndex = 0
const getPullRequest = vi.fn(async () => {
const mergeability = options.mergeabilitySequence?.[mergeabilityIndex++]
return {
data: mergeability
? { ...pr, mergeable: mergeability.mergeable, mergeable_state: mergeability.mergeableState }
: pr,
}
})

const github = {
paginate: vi.fn(async (target: unknown, args: unknown) => {
Expand Down Expand Up @@ -325,11 +334,14 @@ async function runWorkflow(options: HarnessOptions = {}) {
},
},
}
const pullRequestPayload = {
number: 1437,
head: { repo: { full_name: headRepository } },
base: { repo: { full_name: "Zoo-Code-Org/Zoo-Code" } },
}
const pullRequestPayload =
eventName === "push"
? undefined
: {
number: 1437,
head: { repo: { full_name: headRepository } },
base: { repo: { full_name: "Zoo-Code-Org/Zoo-Code" } },
}
const payload =
eventName === "schedule"
? {}
Expand Down Expand Up @@ -413,7 +425,7 @@ describe("PR review-state workflow", () => {

it("keeps privileged event handling metadata-only and least-privilege", () => {
expect(workflow.permissions).toEqual({
"pull-requests": "read",
"pull-requests": "write",
issues: "write",
checks: "read",
statuses: "write",
Expand Down Expand Up @@ -898,6 +910,76 @@ describe("PR review-state workflow", () => {
expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["has-conflicts"] }))
})

it("clears awaiting-maintainer when a main push introduces conflicts", async () => {
const result = await runWorkflow({
eventName: "push",
conflict: true,
labels: ["awaiting-maintainer"],
})

expect(workflow.on.push.branches).toContain("main")
expect(result.listPullRequests).toHaveBeenCalledWith(expect.objectContaining({ state: "open" }))
expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "awaiting-maintainer" }))
expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["has-conflicts"] }))
})

it("preserves awaiting-maintainer when adding has-conflicts fails", async () => {
const result = await runWorkflow({
conflict: true,
labels: ["awaiting-maintainer"],
addLabelsStatus: 500,
})

expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["has-conflicts"] }))
expect(result.removeLabel).not.toHaveBeenCalledWith(expect.objectContaining({ name: "awaiting-maintainer" }))
expect(result.setFailed).toHaveBeenCalledWith(expect.stringContaining("Could not add desired label"))
})

it("rechecks mergeability before tagging a PR awaiting maintainer", async () => {
const result = await runWorkflow({
eventName: "push",
labels: ["awaiting-maintainer"],
mergeabilitySequence: [
{ mergeable: null, mergeableState: "unknown" },
{ mergeable: false, mergeableState: "dirty" },
],
reviews: [
{
login: "coderabbitai[bot]",
type: "Bot",
state: "APPROVED",
submittedAt: REVIEWED_AT,
},
],
})

expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "awaiting-maintainer" }))
expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["has-conflicts"] }))
})

it("does not tag a PR awaiting maintainer while mergeability is unknown", async () => {
const result = await runWorkflow({
eventName: "push",
labels: ["awaiting-maintainer"],
mergeabilitySequence: [
{ mergeable: null, mergeableState: "unknown" },
{ mergeable: null, mergeableState: "unknown" },
],
reviews: [
{
login: "coderabbitai[bot]",
type: "Bot",
state: "APPROVED",
submittedAt: REVIEWED_AT,
},
],
})

expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "awaiting-maintainer" }))
expect(result.addLabels).not.toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-maintainer"] }))
expect(latestGateStatus(result)?.description).toContain("calculating mergeability")
})

it("routes CodeRabbit change requests back to the author", async () => {
const result = await runWorkflow({
labels: ["coderabbit-review-active"],
Expand Down Expand Up @@ -949,12 +1031,16 @@ describe("PR review-state workflow", () => {
},
)

it("recognizes CodeRabbit regardless of login casing", async () => {
it.each([
{ login: "CodeRabbitAI[bot]", type: "Bot" },
{ login: "coderabbitai", type: "User" },
] as const)("recognizes CodeRabbit review login $login", async ({ login, type }) => {
const result = await runWorkflow({
permissionErrorStatus: 500,
reviews: [
{
login: "CodeRabbitAI[bot]",
type: "Bot",
login,
type,
state: "APPROVED",
submittedAt: REVIEWED_AT,
},
Expand Down
Loading