From 901b06a0d8b65b094154c536a620a608fda69894 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:32:58 -0400 Subject: [PATCH 1/2] fix(pr-pipeline): hold owner-PR feedback rounds on a late Hold: line and a lane-escalated needs-human The select job now re-reads an adopted owner PR's body when the round starts and skips awaiting-human on a `Hold:` line added after dispatch, with the same HOLD_LINE ownerSkip uses (moved to address-feedback.mjs and re-exported from feedback-dispatch.mjs for fix-ci-dispatch.mjs). On an adopted owner PR the human-gated label now holds an address-feedback dispatch and select whoever put it on, a lane escalation included; a write-access trusted fix request still dispatches and is worked. Lanes App PRs keep working past a lane escalation's label. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VQb9Q3ucKDVyHvpcU5cfU8 --- ...edback-dispatch-owner-pr-holds-4b8e2c61.md | 8 +++++ .../scripts/pr-unblock/address-feedback.mjs | 26 +++++++++++--- .../pr-unblock/address-feedback.test.mjs | 32 ++++++++++++++++- .../scripts/pr-unblock/feedback-dispatch.mjs | 36 +++++++++---------- .../pr-unblock/feedback-dispatch.test.mjs | 30 ++++++++++++++++ docs/conventions/pr-pipeline/README.md | 6 +++- 6 files changed, 113 insertions(+), 25 deletions(-) create mode 100644 .changes/pr-pipeline/fix-feedback-dispatch-owner-pr-holds-4b8e2c61.md diff --git a/.changes/pr-pipeline/fix-feedback-dispatch-owner-pr-holds-4b8e2c61.md b/.changes/pr-pipeline/fix-feedback-dispatch-owner-pr-holds-4b8e2c61.md new file mode 100644 index 0000000000..a7364119b3 --- /dev/null +++ b/.changes/pr-pipeline/fix-feedback-dispatch-owner-pr-holds-4b8e2c61.md @@ -0,0 +1,8 @@ +--- +bump: patch +--- + +### Fixed + +- On an adopted owner PR, the `address-feedback` select job skips `awaiting-human` when the body gained a `Hold:` line after the round was dispatched, so a hold the owner adds while the round is queued stops it before any model turn. Lanes App PRs, whose `Hold: a human merges this` holds only the merge, are unchanged. +- On an adopted owner PR, the human-gated label holds an `address-feedback` round whoever put it on, a lane escalation included; only a thread a trusted person with write access asked the lane to fix still dispatches and runs. On a lanes App PR a lane escalation's label still lets the round work, as before. diff --git a/.github/scripts/pr-unblock/address-feedback.mjs b/.github/scripts/pr-unblock/address-feedback.mjs index f33979bcdd..ea45b37bfd 100644 --- a/.github/scripts/pr-unblock/address-feedback.mjs +++ b/.github/scripts/pr-unblock/address-feedback.mjs @@ -24,8 +24,10 @@ // reviewed heads the threads were kept on (runRecord). On a PR carrying the // human-gated label it keeps only threads a trusted person with // write access asked it to fix (isFixRequest), and skips -// awaiting-human when there are none, unless a lane escalation put -// the label on and no review lane holds the PR (gate-source.mjs). +// awaiting-human when there are none, unless the PR is the lanes +// App's, a lane escalation put the label on and no review lane +// holds the PR (gate-source.mjs). On an adopted owner PR a body +// line starting `Hold:` skips awaiting-human. // // Every comment body is copied into the thread file and never logged or // interpolated into a command. Only a trusted person's reply to the lane's @@ -610,12 +612,20 @@ export async function select({ api.listReviewComments(pr), api.listThreads(pr), ]); + // An owner PR whose body gained a `Hold:` line after dispatch is held, as + // feedback-dispatch.mjs ownerSkip holds it at dispatch. + const owner = ownerAuthored(pull, adoption); + if (owner && HOLD_LINE.test(String(pull.body ?? ""))) { + return { decision: "skip", skipReason: "awaiting-human", threads: [] }; + } // The human-gated label holds the PR except for threads a trusted person // with write access asked the lane to fix, unless a lane escalation put it - // on and no review lane holds the PR (gate-source.mjs). The label stays on. + // on and no review lane holds the PR (gate-source.mjs) on a lanes App PR. + // On an owner PR the label holds whoever put it on. The label stays on. const labeled = (pull.labels ?? []).some((label) => label.name === humanGatedLabel); const escalated = labeled && + !owner && gateSource({ timeline: [...commentItems(issueComments), ...(await api.listLabelEvents(pr))], label: humanGatedLabel, @@ -657,7 +667,7 @@ export async function select({ // is the owner's (one they wrote, answered, or a lane answer the lanes App // cannot resolve there), and so no stop: it still holds the merge until it // is resolved. Any other person's PR still escalates. - if (untaken && ownerAuthored(pull, adoption)) { + if (untaken && owner) { return { decision: "skip", skipReason: "not-applicable", threads: kept }; } return untaken @@ -728,7 +738,7 @@ export async function select({ maxRounds: bound, // True drops the human-gated label from the write runner's holds: every // kept thread carries a write-access fix request, or a lane escalation - // put the label on. + // put the label on a lanes App PR. fixRequested: labeled, threads: worked, file: { version: 1, repository, pr, head_sha: headSha, pr_paths: prPathsOf(files), threads: worked }, @@ -1109,6 +1119,12 @@ export const NO_ADOPTION = Object.freeze({ ownerIds: Object.freeze(new Set()), i export const ownerAuthored = (pull, adoption = NO_ADOPTION) => adoption.idleHours !== undefined && pull.user?.type === "User" && adoption.ownerIds.has(pull.user?.id); +// A body line starting "Hold:", as babysit's BODY_MERGE_HOLD_RE reads it +// (babysit_merge.py): any case, after any non-newline whitespace, NBSP +// included, with a bare CR as a line break too. On an owner PR it holds the +// lane off: feedback-dispatch.mjs ownerSkip, select, fix-ci-dispatch.mjs. +export const HOLD_LINE = /(?:^|(?<=[\r\n]))[^\S\r\n]*hold:/i; + // The owners whose PRs a round serves: the base trusted-actors list's people, // under the base config's `adoption` section. An unreadable section adopts no // owner, and lanes App PRs proceed. diff --git a/.github/scripts/pr-unblock/address-feedback.test.mjs b/.github/scripts/pr-unblock/address-feedback.test.mjs index abd69505ef..351051ec3b 100644 --- a/.github/scripts/pr-unblock/address-feedback.test.mjs +++ b/.github/scripts/pr-unblock/address-feedback.test.mjs @@ -46,7 +46,7 @@ function fakeApi(overrides = {}) { posted, permissionReads, // The lanes App opened the PR unless the case names another opener. - getPull: async () => ({ number: 42, user: overrides.opener ? { id: overrides.opener, type: "User" } : { id: APP, type: "Bot" }, labels: (overrides.labels ?? []).map((name) => ({ name })) }), + getPull: async () => ({ number: 42, user: overrides.opener ? { id: overrides.opener, type: "User" } : { id: APP, type: "Bot" }, labels: (overrides.labels ?? []).map((name) => ({ name })), body: overrides.body ?? "" }), async getPermission(login) { permissionReads.push(login); return (overrides.permissions ?? { "kyle-sexton": "write" })[login] ?? "none"; @@ -1639,6 +1639,36 @@ test("on an adopted owner PR an untaken thread skips with no stop; elsewhere it assert.equal((await run(fakeApi({ ...threads, opener: HUMAN }))).escalationReason, "feedback-untaken-threads", "an owner PR with no adoption section"); }); +// A `Hold:` body line an owner adds after dispatch holds the round as +// feedback-dispatch.mjs ownerSkip holds it at dispatch; a lanes App PR's own +// `Hold: a human merges this` holds only its merge. +test("on an adopted owner PR a Hold: body line added after dispatch skips awaiting-human before any thread is kept", async () => { + const adoption = { ownerIds: new Set([HUMAN]), idleHours: 4 }; + const adopted = (api) => select({ api, pr: 42, headSha: HEAD, repository: "o/r", appId: APP, reviewerId: REVIEWER, trustedHumans: new Set([HUMAN]), adoption }); + const held = await adopted(fakeApi({ opener: HUMAN, body: "Closes #1\r\n HOLD: still on this" })); + assert.deepEqual(held, { decision: "skip", skipReason: "awaiting-human", threads: [] }); + assert.equal((await adopted(fakeApi({ opener: HUMAN, body: "Closes #1\n\nNo hold here." }))).decision, "proceed", "an owner PR with no Hold: line"); + assert.equal((await adopted(fakeApi({ body: "Closes #1\n\nHold: a human merges this" }))).decision, "proceed", "a lanes App PR's Hold: line"); +}); + +// On an adopted owner PR the human-gated label holds whoever put it on, a +// lane escalation included; a write-access fix request still lifts it. A +// lanes App PR keeps working past a lane escalation (the test above). +test("on an adopted owner PR a lane escalation's needs-human holds a reviewer-bot thread; a write-access fix request proceeds", async () => { + const adoption = { ownerIds: new Set([HUMAN]), idleHours: 4 }; + const adopted = (api) => select({ api, pr: 42, headSha: HEAD, repository: "o/r", appId: APP, reviewerId: REVIEWER, trustedHumans: new Set([HUMAN]), adoption }); + const gate = { labels: ["needs-human"], issueComments: [reviewedHead, LANE_ESCALATION], labelEvents: APP_LABELED }; + const botOnly = await adopted(fakeApi({ ...gate, opener: HUMAN })); + assert.equal(botOnly.decision, "skip"); + assert.equal(botOnly.skipReason, "awaiting-human"); + const asked = await adopted(fakeApi({ ...gate, ...escalated({ replies: [[102, HUMAN, REQUEST]] }), opener: HUMAN })); + assert.equal(asked.decision, "proceed"); + assert.equal(asked.fixRequested, true); + assert.deepEqual(asked.file.threads.map((thread) => thread.root_comment_id), [100]); + const appPr = await adopted(fakeApi(gate)); + assert.equal(appPr.decision, "proceed", "a lanes App PR under the same adoption section"); +}); + test("on an owner PR a thread a person opened is never kept", async () => { const result = await run(fakeApi({ opener: HUMAN, reviewComments: [comment(100, HUMAN)], threads: [thread("PRRT_1", [100])] })); assert.deepEqual(result.threads, []); diff --git a/.github/scripts/pr-unblock/feedback-dispatch.mjs b/.github/scripts/pr-unblock/feedback-dispatch.mjs index 0495f8afff..854dabf6e4 100644 --- a/.github/scripts/pr-unblock/feedback-dispatch.mjs +++ b/.github/scripts/pr-unblock/feedback-dispatch.mjs @@ -29,7 +29,7 @@ // asked the lane to fix it (isFixRequest), or a trusted person answered the // lane's escalation (address-feedback.mjs threadAuthors); on a PR carrying // the human-gated label, the same when a lane escalation put the label on -// and no review lane holds the PR (gate-source.mjs gateSource), and +// a lanes App PR and no review lane holds it (gate-source.mjs gateSource), // otherwise only a thread where a trusted person with write access asked // the lane to fix it (isFixRequest); // - that head has no pr-unblock / address-feedback check run yet (one the @@ -125,9 +125,10 @@ import { pathToFileURL } from "node:url"; import { commitPulls } from "../../actions/check-trusted-trigger/check-trusted-trigger.mjs"; import { countRequests } from "../../actions/log-api-budget/rate-guard.mjs"; import { LOOKBACK_HOURS, LOST } from "../maintenance-reconcile-lost-runs/infra-failure.mjs"; -import { fixRequesters, humanGatedLabelFrom, NO_ADOPTION, onReviewedHead, ownerAdoption, ownerAuthored, reviewedHeads, reviewerIdsFrom, threadAuthors, trustedHumansFrom } from "./address-feedback.mjs"; +import { fixRequesters, HOLD_LINE, humanGatedLabelFrom, NO_ADOPTION, onReviewedHead, ownerAdoption, ownerAuthored, reviewedHeads, reviewerIdsFrom, threadAuthors, trustedHumansFrom } from "./address-feedback.mjs"; -export { NO_ADOPTION, ownerAdoption, ownerAuthored }; +// fix-ci-dispatch.mjs imports HOLD_LINE from here. +export { HOLD_LINE, NO_ADOPTION, ownerAdoption, ownerAuthored }; import { commentItems, gateSource, listLabelEvents } from "./gate-source.mjs"; import { emptyRecord, isHandled, markHandled, readRecord, writeRecord } from "./dispatch-record.mjs"; import { anyHandles, KILL_SWITCH_STOP, LANE_STOP, laneStop, limitResetsAt, permanentlyHandles } from "./limit-stop.mjs"; @@ -164,14 +165,10 @@ export const DEFAULTS = Object.freeze({ const SHA = /^[0-9a-f]{40}$/; const RUN_ID = /^[1-9][0-9]{0,14}$/; -// A body line starting "Hold:", as babysit's BODY_MERGE_HOLD_RE reads it -// (babysit_merge.py): any case, after any non-newline whitespace, NBSP -// included, with a bare CR as a line break too. fix-ci-dispatch.mjs imports -// it from here, so this floored module loads no unfloored one. -export const HOLD_LINE = /(?:^|(?<=[\r\n]))[^\S\r\n]*hold:/i; // Why an owner PR is not adopted for a round now, or "": as fix-ci adopts one -// (fix-ci-dispatch.mjs skipReason), no body line starting `Hold:`, and both +// (fix-ci-dispatch.mjs skipReason), no body line starting `Hold:` (the select +// job checks it again, as the round starts), and both // the PR's last update and its head commit at least idle-hours old, so the // lane does not push onto a branch a session is still working on. An // unreadable date is not idle. @@ -388,17 +385,20 @@ export async function dispatchFeedback({ const threads = await api.listReviewThreads(number); // A human-gated PR dispatches only for a thread a trusted person with // write access asked the lane to fix, unless a lane escalation put the - // label on and no review lane holds the PR. The label stays on either way: + // label on a lanes App PR and no review lane holds it; on an owner PR the + // label holds whoever put it on. The label stays on either way: // clear-escalations.mjs owns taking it off. const labeled = (pull.labels ?? []).some((label) => label.name === humanGatedLabel); - const gate = labeled - ? gateSource({ - timeline: [...commentItems(comments), ...(await api.listLabelEvents(number))], - label: humanGatedLabel, - head, - isLanesApp: (user) => user?.id === appSenderId && user?.type === "Bot", - }) - : null; + const gate = !labeled + ? null + : owner + ? { source: "owner PR", reason: "any source holds" } + : gateSource({ + timeline: [...commentItems(comments), ...(await api.listLabelEvents(number))], + label: humanGatedLabel, + head, + isLanesApp: (user) => user?.id === appSenderId && user?.type === "Bot", + }); const gated = labeled && gate.source !== "lane-escalation"; const fixers = await fixRequesters({ comments: threads.flatMap((thread) => diff --git a/.github/scripts/pr-unblock/feedback-dispatch.test.mjs b/.github/scripts/pr-unblock/feedback-dispatch.test.mjs index 072beabfde..f1da62397c 100644 --- a/.github/scripts/pr-unblock/feedback-dispatch.test.mjs +++ b/.github/scripts/pr-unblock/feedback-dispatch.test.mjs @@ -1994,6 +1994,36 @@ test("on an owner PR a thread a person wrote or answered stays the person's and } }); +// A lane escalation's needs-human holds an owner PR's round as any other +// source does; a write-access fix request still lifts it, as on a lanes App PR. +test("an owner PR a lane escalation gated dispatches nothing for a reviewer-bot thread", async () => { + const api = ownerApi({ pulls: ownerPull({ labels: [{ name: "needs-human" }] }), comments: [...reviewed, escalationComment()], labelEvents: [appLabeled] }); + const { result, lines } = await decideOwner(api); + assert.equal(result, null, lines.join("\n")); + assert.match(lines.at(-1), /#42 is labeled needs-human \(owner PR: any source holds\) and has no thread a trusted person with write access asked the lane to fix/); + assert.deepEqual(api.calls.dispatch, []); +}); + +test("an owner PR a lane escalation gated dispatches for a write-access trusted person's fix request", async () => { + const api = ownerApi({ + pulls: ownerPull({ labels: [{ name: "needs-human" }] }), + comments: [...reviewed, escalationComment()], + labelEvents: [appLabeled], + threads: [decidedThread({ author: KYLE_LOGIN, body: REQUEST })], + }); + const { result, lines } = await decideOwner(api); + assert.equal(result?.number, 42, lines.join("\n")); + assert.equal(api.calls.dispatch.length, 1); + assert.deepEqual(api.calls.permission, ["kyle-sexton"]); +}); + +test("a lanes App PR a lane escalation gated still dispatches for a reviewer-bot thread under an adoption section", async () => { + const api = ownerApi({ pulls: humanGated(), comments: [...reviewed, escalationComment()], labelEvents: [appLabeled] }); + const { result, lines } = await decideOwner(api); + assert.equal(result?.number, 42, lines.join("\n")); + assert.equal(api.calls.dispatch.length, 1); +}); + test("a lanes App PR is decided as before under an adoption section: no idle read", async () => { const api = ownerApi({ pulls: pull({ updated_at: "2026-10-28T11:59:00Z" }) }, "2026-10-28T11:59:00Z"); const { result } = await decideOwner(api); diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index 2be0c621bb..9a9f39a59b 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -261,7 +261,11 @@ serves an adopted owner PR, posting its replies as the lanes App: `IDLE_OWNER_WINDOW_HOURS` of the PR turning idle, so a head with nothing for the lane is not re-read every tick; any new update opens the window again. A head whose round stopped with a failing `pr-unblock/address-feedback` status, and no thread left, is dispatched to clear it, as - for a lanes App PR, under the same `Hold:` and idle gates. + for a lanes App PR, under the same `Hold:` and idle gates. The select job reads the body again + when the round starts and skips `awaiting-human` on a `Hold:` line added after dispatch. +- Human gate: the human-gated label holds the round whoever put it on, a lane escalation included, + so only a thread a trusted person with write access asked the lane to fix is worked. On a lanes + App PR a lane escalation's label still lets the round work. - What: the round is the App PR round, unchanged: the same threads (only the reviewer bot's, a listed review bot's, or a lane's, under the same trusted-person rules), the same CRITICAL and security holds, the same `apply-feedback-patch` push under the same patch rules, and no merge. From 1a286e571f69e19faf41cac19c52807c52c3ad0a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:40:16 -0400 Subject: [PATCH 2/2] fix(pr-pipeline): gate owner-PR feedback rounds on fix-ci's lane-escalation rule Replace the any-source human-gate on adopted owner PRs with the rule fix-ci already applies there: gate-source.mjs prHold (laneEscalationAllows), so a lane escalation's needs-human holds an address-feedback dispatch and select only after address-feedback's own escalation or once a person spoke after the label went on. A write-access trusted fix request still dispatches and is worked; lanes App PRs still read the escalation through gateSource alone. feedback-dispatch's API gains the review-comment and review listings prHold's reviewReader reads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VQb9Q3ucKDVyHvpcU5cfU8 --- ...edback-dispatch-owner-pr-holds-4b8e2c61.md | 2 +- .../scripts/pr-unblock/address-feedback.mjs | 47 +++++++++----- .../pr-unblock/address-feedback.test.mjs | 42 +++++++++---- .../scripts/pr-unblock/feedback-dispatch.mjs | 60 +++++++++++++----- .../pr-unblock/feedback-dispatch.test.mjs | 62 ++++++++++++++----- docs/conventions/pr-pipeline/README.md | 8 ++- 6 files changed, 154 insertions(+), 67 deletions(-) diff --git a/.changes/pr-pipeline/fix-feedback-dispatch-owner-pr-holds-4b8e2c61.md b/.changes/pr-pipeline/fix-feedback-dispatch-owner-pr-holds-4b8e2c61.md index a7364119b3..256a6a54fd 100644 --- a/.changes/pr-pipeline/fix-feedback-dispatch-owner-pr-holds-4b8e2c61.md +++ b/.changes/pr-pipeline/fix-feedback-dispatch-owner-pr-holds-4b8e2c61.md @@ -5,4 +5,4 @@ bump: patch ### Fixed - On an adopted owner PR, the `address-feedback` select job skips `awaiting-human` when the body gained a `Hold:` line after the round was dispatched, so a hold the owner adds while the round is queued stops it before any model turn. Lanes App PRs, whose `Hold: a human merges this` holds only the merge, are unchanged. -- On an adopted owner PR, the human-gated label holds an `address-feedback` round whoever put it on, a lane escalation included; only a thread a trusted person with write access asked the lane to fix still dispatches and runs. On a lanes App PR a lane escalation's label still lets the round work, as before. +- On an adopted owner PR, a lane escalation's human-gated label lets an `address-feedback` round dispatch and run only as it lets fix-ci work the PR (`gate-source.mjs` `prHold`): it holds after an `address-feedback` escalation, or once a person commented or reviewed after the label went on, and then only a thread a trusted person with write access asked the lane to fix is worked. On a lanes App PR a lane escalation's label still lets the round work, as before. diff --git a/.github/scripts/pr-unblock/address-feedback.mjs b/.github/scripts/pr-unblock/address-feedback.mjs index ea45b37bfd..182c1c11e7 100644 --- a/.github/scripts/pr-unblock/address-feedback.mjs +++ b/.github/scripts/pr-unblock/address-feedback.mjs @@ -24,10 +24,11 @@ // reviewed heads the threads were kept on (runRecord). On a PR carrying the // human-gated label it keeps only threads a trusted person with // write access asked it to fix (isFixRequest), and skips -// awaiting-human when there are none, unless the PR is the lanes -// App's, a lane escalation put the label on and no review lane -// holds the PR (gate-source.mjs). On an adopted owner PR a body -// line starting `Hold:` skips awaiting-human. +// awaiting-human when there are none, unless a lane escalation put +// the label on and no review lane holds the PR (gate-source.mjs), +// and on an adopted owner PR only as prHold lets fix-ci work it. +// On an adopted owner PR a body line starting `Hold:` skips +// awaiting-human. // // Every comment body is copied into the thread file and never logged or // interpolated into a command. Only a trusted person's reply to the lane's @@ -39,7 +40,7 @@ import { appendFileSync, lstatSync, readdirSync, readFileSync, writeFileSync } f import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import { commentItems, gateSource, laneHold, listLabelEvents, ROUND_MARKER } from "./gate-source.mjs"; +import { commentItems, gateSource, laneHold, listLabelEvents, prHold, ROUND_MARKER } from "./gate-source.mjs"; import { retryFetch } from "./retry-fetch.mjs"; import { isProtectedPath, namedInScope, prPathsOf } from "./thread-scope.mjs"; import { retryRung, stopSkip } from "./escalation-routes.mjs"; @@ -55,6 +56,8 @@ export const FRESH_FIXER_ROUNDS = 1; // How the round marker and the neutral checks show the count. export const roundLine = (round, max) => `round ${round} of max ${max}`; export { ROUND_MARKER }; +// The activity select decides, as escalation-routes.json names it. +const ACTIVITY = "address-feedback"; // github-actions[bot], the author of the review lanes' reviewed-head markers. export const MARKER_AUTHOR_ID = 41898282; const REVIEWED_HEAD = @@ -620,19 +623,29 @@ export async function select({ } // The human-gated label holds the PR except for threads a trusted person // with write access asked the lane to fix, unless a lane escalation put it - // on and no review lane holds the PR (gate-source.mjs) on a lanes App PR. - // On an owner PR the label holds whoever put it on. The label stays on. + // on and no review lane holds the PR (gate-source.mjs). On an owner PR the + // lane escalation lets the round work only as it lets fix-ci work there + // (prHold): not after address-feedback's own escalation, nor once a person + // spoke after the label went on. The label stays on. const labeled = (pull.labels ?? []).some((label) => label.name === humanGatedLabel); - const escalated = + const timeline = async () => [...commentItems(issueComments), ...(await api.listLabelEvents(pr))]; + const isLanesApp = (user) => user?.id === appId && user?.type === "Bot"; + const gated = labeled && - !owner && - gateSource({ - timeline: [...commentItems(issueComments), ...(await api.listLabelEvents(pr))], - label: humanGatedLabel, - head: headSha, - isLanesApp: (user) => user?.id === appId && user?.type === "Bot", - }).source === "lane-escalation"; - const gated = labeled && !escalated; + (owner + ? ( + await prHold({ + labels: pull.labels.map((label) => label.name), + holdLabels: [], + gateLabel: humanGatedLabel, + head: headSha, + activity: ACTIVITY, + appId, + readTimeline: timeline, + readReviews: async () => ({ comments: reviewComments, reviews }), + }) + ).held + : gateSource({ timeline: await timeline(), label: humanGatedLabel, head: headSha, isLanesApp }).source !== "lane-escalation"); const fixers = await reviewFixers({ reviewComments, trustedHumans, permission: api.getPermission }); const heads = reviewedHeads(issueComments); const kept = keepThreads({ @@ -738,7 +751,7 @@ export async function select({ maxRounds: bound, // True drops the human-gated label from the write runner's holds: every // kept thread carries a write-access fix request, or a lane escalation - // put the label on a lanes App PR. + // that lets this activity work put the label on. fixRequested: labeled, threads: worked, file: { version: 1, repository, pr, head_sha: headSha, pr_paths: prPathsOf(files), threads: worked }, diff --git a/.github/scripts/pr-unblock/address-feedback.test.mjs b/.github/scripts/pr-unblock/address-feedback.test.mjs index 351051ec3b..b728eefedb 100644 --- a/.github/scripts/pr-unblock/address-feedback.test.mjs +++ b/.github/scripts/pr-unblock/address-feedback.test.mjs @@ -1651,22 +1651,38 @@ test("on an adopted owner PR a Hold: body line added after dispatch skips awaiti assert.equal((await adopted(fakeApi({ body: "Closes #1\n\nHold: a human merges this" }))).decision, "proceed", "a lanes App PR's Hold: line"); }); -// On an adopted owner PR the human-gated label holds whoever put it on, a -// lane escalation included; a write-access fix request still lifts it. A -// lanes App PR keeps working past a lane escalation (the test above). -test("on an adopted owner PR a lane escalation's needs-human holds a reviewer-bot thread; a write-access fix request proceeds", async () => { +// On an adopted owner PR a lane escalation's needs-human lets the round work +// only as it lets fix-ci work there (gate-source.mjs prHold): another lane's +// escalation with no person speaking since the label does; address-feedback's +// own escalation, or a person's review comment since it, holds the PR, and +// then only a write-access fix request is kept. A lanes App PR reads the +// escalation through gateSource alone, as before. Every author is typed as the +// REST API returns it, so a bot is no person speaking. +test("on an adopted owner PR a lane escalation's needs-human lets the round work only as fix-ci's prHold does", async () => { const adoption = { ownerIds: new Set([HUMAN]), idleHours: 4 }; const adopted = (api) => select({ api, pr: 42, headSha: HEAD, repository: "o/r", appId: APP, reviewerId: REVIEWER, trustedHumans: new Set([HUMAN]), adoption }); - const gate = { labels: ["needs-human"], issueComments: [reviewedHead, LANE_ESCALATION], labelEvents: APP_LABELED }; - const botOnly = await adopted(fakeApi({ ...gate, opener: HUMAN })); - assert.equal(botOnly.decision, "skip"); - assert.equal(botOnly.skipReason, "awaiting-human"); - const asked = await adopted(fakeApi({ ...gate, ...escalated({ replies: [[102, HUMAN, REQUEST]] }), opener: HUMAN })); - assert.equal(asked.decision, "proceed"); - assert.equal(asked.fixRequested, true); + const escalation = (reason) => ({ ...LANE_ESCALATION, body: `${LANE_ESCALATION.body}\n\n- Reason: \`${reason}\`` }); + const bot = { id: REVIEWER, login: LOGINS[REVIEWER], type: "Bot" }; + const gate = (reason, extra = []) => ({ + labels: ["needs-human"], + issueComments: [{ ...reviewedHead, user: { id: ACTIONS_BOT, type: "Bot" } }, escalation(reason)], + labelEvents: APP_LABELED, + reviewComments: [comment(100, REVIEWER, { user: bot, created_at: "2026-10-18T09:00:00Z", updated_at: "2026-10-18T09:00:00Z" }), ...extra], + reviews: [{ id: 10, user: bot, commit_id: HEAD, submitted_at: "2026-10-18T09:00:00Z" }], + }); + const other = await adopted(fakeApi({ ...gate("ci-fix-round-cap"), opener: HUMAN })); + assert.equal(other.decision, "proceed", "another lane's escalation, no person since"); + assert.equal(other.fixRequested, true); + const own = await adopted(fakeApi({ ...gate("feedback-round-cap"), opener: HUMAN })); + assert.deepEqual([own.decision, own.skipReason], ["skip", "awaiting-human"], "address-feedback's own escalation"); + const spoke = comment(101, HUMAN, { created_at: "2026-10-18T11:00:00Z", updated_at: "2026-10-18T11:00:00Z" }); + const person = await adopted(fakeApi({ ...gate("ci-fix-round-cap", [spoke]), opener: HUMAN })); + assert.deepEqual([person.decision, person.skipReason], ["skip", "awaiting-human"], "a person's review comment since the label"); + const asked = await adopted(fakeApi({ ...gate("feedback-round-cap"), ...escalated({ replies: [[102, HUMAN, REQUEST]] }), opener: HUMAN })); + assert.equal(asked.decision, "proceed", "a write-access fix request on a held owner PR"); assert.deepEqual(asked.file.threads.map((thread) => thread.root_comment_id), [100]); - const appPr = await adopted(fakeApi(gate)); - assert.equal(appPr.decision, "proceed", "a lanes App PR under the same adoption section"); + const appPr = await adopted(fakeApi(gate("feedback-round-cap", [spoke]))); + assert.equal(appPr.decision, "proceed", "a lanes App PR under its own escalation"); }); test("on an owner PR a thread a person opened is never kept", async () => { diff --git a/.github/scripts/pr-unblock/feedback-dispatch.mjs b/.github/scripts/pr-unblock/feedback-dispatch.mjs index 854dabf6e4..a67f51eade 100644 --- a/.github/scripts/pr-unblock/feedback-dispatch.mjs +++ b/.github/scripts/pr-unblock/feedback-dispatch.mjs @@ -29,7 +29,8 @@ // asked the lane to fix it (isFixRequest), or a trusted person answered the // lane's escalation (address-feedback.mjs threadAuthors); on a PR carrying // the human-gated label, the same when a lane escalation put the label on -// a lanes App PR and no review lane holds it (gate-source.mjs gateSource), +// and no review lane holds the PR (gate-source.mjs gateSource), on an owner +// PR only as it lets fix-ci work there (prHold), // otherwise only a thread where a trusted person with write access asked // the lane to fix it (isFixRequest); // - that head has no pr-unblock / address-feedback check run yet (one the @@ -129,7 +130,7 @@ import { fixRequesters, HOLD_LINE, humanGatedLabelFrom, NO_ADOPTION, onReviewedH // fix-ci-dispatch.mjs imports HOLD_LINE from here. export { HOLD_LINE, NO_ADOPTION, ownerAdoption, ownerAuthored }; -import { commentItems, gateSource, listLabelEvents } from "./gate-source.mjs"; +import { commentItems, gateSource, listLabelEvents, prHold, reviewReader } from "./gate-source.mjs"; import { emptyRecord, isHandled, markHandled, readRecord, writeRecord } from "./dispatch-record.mjs"; import { anyHandles, KILL_SWITCH_STOP, LANE_STOP, laneStop, limitResetsAt, permanentlyHandles } from "./limit-stop.mjs"; import { budgetedFetch } from "./request-budget.mjs"; @@ -385,21 +386,29 @@ export async function dispatchFeedback({ const threads = await api.listReviewThreads(number); // A human-gated PR dispatches only for a thread a trusted person with // write access asked the lane to fix, unless a lane escalation put the - // label on a lanes App PR and no review lane holds it; on an owner PR the - // label holds whoever put it on. The label stays on either way: - // clear-escalations.mjs owns taking it off. + // label on and no review lane holds the PR; on an owner PR only as that + // escalation lets fix-ci work it (gate-source.mjs prHold). The label + // stays on either way: clear-escalations.mjs owns taking it off. const labeled = (pull.labels ?? []).some((label) => label.name === humanGatedLabel); - const gate = !labeled - ? null - : owner - ? { source: "owner PR", reason: "any source holds" } - : gateSource({ - timeline: [...commentItems(comments), ...(await api.listLabelEvents(number))], - label: humanGatedLabel, - head, - isLanesApp: (user) => user?.id === appSenderId && user?.type === "Bot", - }); - const gated = labeled && gate.source !== "lane-escalation"; + const timeline = async () => [...commentItems(comments), ...(await api.listLabelEvents(number))]; + let gated = false; + let why = ""; + if (labeled && owner) { + ({ held: gated, why } = await prHold({ + labels: pull.labels.map((label) => label.name), + holdLabels: [], + gateLabel: humanGatedLabel, + head, + activity: settings.activity, + appId: appSenderId, + readTimeline: timeline, + readReviews: reviewReader(api, number), + })); + } else if (labeled) { + const gate = gateSource({ timeline: await timeline(), label: humanGatedLabel, head, isLanesApp: (user) => user?.id === appSenderId && user?.type === "Bot" }); + gated = gate.source !== "lane-escalation"; + why = `${gate.source}: ${gate.reason}`; + } const fixers = await fixRequesters({ comments: threads.flatMap((thread) => thread.comments.nodes.map((node) => ({ authorId: node.author?.databaseId, login: node.author?.login, body: node.body })), @@ -414,7 +423,7 @@ export async function dispatchFeedback({ // lane-stopped check handles the head, so the stall gets a stop once. const workable = hasWorkableThread(threads, { reviewerId: settings.reviewerId, reviewerIds, appId: appSenderId, trustedHumans, heads: reviewedHeads(comments), requesters, fixers }); if (gated && !workable) { - return skip(`#${number} is labeled ${humanGatedLabel} (${gate.source}: ${gate.reason}) and has no thread a trusted person with write access asked the lane to fix`); + return skip(`#${number} is labeled ${humanGatedLabel} (${why}) and has no thread a trusted person with write access asked the lane to fix`); } if (!workable && !threads.some((thread) => thread.isResolved === false)) { return skip(`#${number} has no unresolved review thread`); @@ -803,6 +812,19 @@ export function createGitHubApi({ token, repository, apiUrl, graphqlUrl, fetch: return response.status === 204 ? {} : response.json(); } + // Every page of a REST listing, at most 50. + async function pages(url) { + const rows = []; + for (let page = 1; page <= 50; page += 1) { + const batch = await request(`${url}&page=${page}`); + rows.push(...batch); + if (batch.length < 100) { + return rows; + } + } + throw new Error(`${url} has more than 50 pages`); + } + async function graphql(query, variables, signal) { const result = await request(graphqlUrl, "POST", { query, variables }, signal); if (result.errors?.length) { @@ -844,6 +866,10 @@ export function createGitHubApi({ token, repository, apiUrl, graphqlUrl, fetch: throw new Error(`#${number} has more than 5000 comments`); }, listLabelEvents: (number) => listLabelEvents({ graphql, repository, number }), + // prHold's review reads (gate-source.mjs reviewReader), on an owner PR + // carrying the human-gated label only. + listReviewComments: (number, since) => pages(`${repo}/pulls/${number}/comments?since=${encodeURIComponent(since)}&per_page=100`), + listReviews: (number) => pages(`${repo}/pulls/${number}/reviews?per_page=100`), async listReviewThreads(number) { const threads = []; let after = null; diff --git a/.github/scripts/pr-unblock/feedback-dispatch.test.mjs b/.github/scripts/pr-unblock/feedback-dispatch.test.mjs index f1da62397c..047eb676dc 100644 --- a/.github/scripts/pr-unblock/feedback-dispatch.test.mjs +++ b/.github/scripts/pr-unblock/feedback-dispatch.test.mjs @@ -92,8 +92,10 @@ function fakeApi({ workflowRunId = 38779200001, permissions = { "kyle-sexton": "admin" }, labelEvents = [], + reviewComments = [], + reviews = [], } = {}) { - const calls = { dispatch: [], permission: [], labelEvents: 0 }; + const calls = { dispatch: [], permission: [], labelEvents: 0, reviewReads: [] }; return { calls, // The event's workflow_run, which the script takes in place of a read. @@ -119,6 +121,11 @@ function fakeApi({ calls.labelEvents += 1; return labelEvents; }, + async listReviewComments(number, since) { + calls.reviewReads.push(since); + return reviewComments; + }, + listReviews: async () => reviews, async getPermission(login) { calls.permission.push(login); return permissions[login] ?? "none"; @@ -1994,34 +2001,57 @@ test("on an owner PR a thread a person wrote or answered stays the person's and } }); -// A lane escalation's needs-human holds an owner PR's round as any other -// source does; a write-access fix request still lifts it, as on a lanes App PR. -test("an owner PR a lane escalation gated dispatches nothing for a reviewer-bot thread", async () => { - const api = ownerApi({ pulls: ownerPull({ labels: [{ name: "needs-human" }] }), comments: [...reviewed, escalationComment()], labelEvents: [appLabeled] }); +// On an owner PR a lane escalation's needs-human lets the round work only as +// it lets fix-ci work there (gate-source.mjs prHold): another lane's +// escalation with no person speaking since the label does; address-feedback's +// own escalation, or a person's review comment since the label, holds it, and +// then only a write-access fix request dispatches. A lanes App PR reads the +// escalation through gateSource alone, as before. +const ownFeedbackEscalation = () => + escalationComment({ body: `\n${RESUME}\nThe pr-unblock lane stopped.\n\n- Reason: \`feedback-round-cap\`` }); +// The review markers as the REST API returns them, typed Bot, so they are no +// person speaking. +const botReviewed = [marker("claude-review", HEAD, ACTIONS_USER), marker("claude-security-review", HEAD, ACTIONS_USER)]; +const ownerGated = (options = {}) => + ownerApi({ pulls: ownerPull({ labels: [{ name: "needs-human" }] }), comments: [...botReviewed, escalationComment()], labelEvents: [appLabeled], ...options }); +const personSpoke = [{ user: { login: KYLE_LOGIN.login, id: KYLE.databaseId, type: "User" }, created_at: "2026-10-18T11:00:00Z", updated_at: "2026-10-18T11:00:00Z" }]; + +test("an owner PR another lane's escalation gated dispatches for a reviewer-bot thread when no person spoke since", async () => { + const api = ownerGated(); const { result, lines } = await decideOwner(api); - assert.equal(result, null, lines.join("\n")); - assert.match(lines.at(-1), /#42 is labeled needs-human \(owner PR: any source holds\) and has no thread a trusted person with write access asked the lane to fix/); - assert.deepEqual(api.calls.dispatch, []); + assert.equal(result?.number, 42, lines.join("\n")); + assert.equal(api.calls.dispatch.length, 1); + // The review comments are read from a second before the label went on. + assert.deepEqual(api.calls.reviewReads, ["2026-10-18T09:59:59Z"]); }); -test("an owner PR a lane escalation gated dispatches for a write-access trusted person's fix request", async () => { - const api = ownerApi({ - pulls: ownerPull({ labels: [{ name: "needs-human" }] }), - comments: [...reviewed, escalationComment()], - labelEvents: [appLabeled], - threads: [decidedThread({ author: KYLE_LOGIN, body: REQUEST })], +for (const [label, options, why] of [ + ["address-feedback's own escalation", { comments: [...botReviewed, ownFeedbackEscalation()] }, "address-feedback escalated it"], + ["a person's review comment since the label", { reviewComments: personSpoke }, "a person commented or reviewed since needs-human went on"], +]) { + test(`an owner PR a lane escalation gated dispatches nothing for a reviewer-bot thread after ${label}`, async () => { + const api = ownerGated(options); + const { result, lines } = await decideOwner(api); + assert.equal(result, null, lines.join("\n")); + assert.match(lines.at(-1), new RegExp(`#42 is labeled needs-human \\(held by needs-human: ${why}\\) and has no thread a trusted person with write access asked the lane to fix`)); + assert.deepEqual(api.calls.dispatch, []); }); +} + +test("an owner PR a lane escalation holds still dispatches for a write-access trusted person's fix request", async () => { + const api = ownerGated({ reviewComments: personSpoke, threads: [decidedThread({ author: KYLE_LOGIN, body: REQUEST })] }); const { result, lines } = await decideOwner(api); assert.equal(result?.number, 42, lines.join("\n")); assert.equal(api.calls.dispatch.length, 1); assert.deepEqual(api.calls.permission, ["kyle-sexton"]); }); -test("a lanes App PR a lane escalation gated still dispatches for a reviewer-bot thread under an adoption section", async () => { - const api = ownerApi({ pulls: humanGated(), comments: [...reviewed, escalationComment()], labelEvents: [appLabeled] }); +test("a lanes App PR under its own address-feedback escalation still dispatches for a reviewer-bot thread under an adoption section", async () => { + const api = ownerApi({ pulls: humanGated(), comments: [...botReviewed, ownFeedbackEscalation()], labelEvents: [appLabeled], reviewComments: personSpoke }); const { result, lines } = await decideOwner(api); assert.equal(result?.number, 42, lines.join("\n")); assert.equal(api.calls.dispatch.length, 1); + assert.deepEqual(api.calls.reviewReads, []); }); test("a lanes App PR is decided as before under an adoption section: no idle read", async () => { diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index 9a9f39a59b..f679e1fc25 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -263,9 +263,11 @@ serves an adopted owner PR, posting its replies as the lanes App: failing `pr-unblock/address-feedback` status, and no thread left, is dispatched to clear it, as for a lanes App PR, under the same `Hold:` and idle gates. The select job reads the body again when the round starts and skips `awaiting-human` on a `Hold:` line added after dispatch. -- Human gate: the human-gated label holds the round whoever put it on, a lane escalation included, - so only a thread a trusted person with write access asked the lane to fix is worked. On a lanes - App PR a lane escalation's label still lets the round work. +- Human gate: a lane escalation's human-gated label lets the round work only as it lets fix-ci + work the PR (`gate-source.mjs` `prHold`): not after an `address-feedback` escalation, and not + once a person commented or reviewed after the label went on. Held, the round works only a thread + a trusted person with write access asked the lane to fix. On a lanes App PR a lane escalation's + label still lets the round work, as before. - What: the round is the App PR round, unchanged: the same threads (only the reviewer bot's, a listed review bot's, or a lane's, under the same trusted-person rules), the same CRITICAL and security holds, the same `apply-feedback-patch` push under the same patch rules, and no merge.