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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: minor
---

### Changed

- `address-feedback` works a pull request carrying the human-gated label when a lane escalation (pr-unblock or fix-ci) put the label on and no review-lane hold names the current head or is open; a review-lane hold, or anything ambiguous, still limits it to threads a trusted person with write access asked it to fix. The label stays on: `clear-escalations` removes it only while no address-feedback round has been recorded since it went on, so once a round runs a person removes it. To stop the lane working such a PR, add `do-not-merge`.
61 changes: 18 additions & 43 deletions .github/scripts/pr-merge/clear-escalations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,25 @@ import { pathToFileURL } from "node:url";

import { createGitHub, paginate } from "../../actions/check-trusted-trigger/check-trusted-trigger.mjs";
import { humanGatedLabelFrom } from "../pr-unblock/address-feedback.mjs";
import {
CLEARED,
ESCALATION,
escalationsSince,
foreignMarker,
isApp,
labelHistory,
laneHold,
lines,
openReviewHold,
PAIRED_MS,
RESUME_KEY,
same,
} from "../pr-unblock/gate-source.mjs";
import { isProtectedPath } from "../pr-unblock/thread-scope.mjs";

const DEFAULT_BOT = "melodic-automation-lanes[bot]";
const ESCALATION = "<!-- work-items:escalation lane=pr-unblock kind=escalated -->";
const RESUME_KEY = /^<!-- pr-unblock resume-key=([0-9a-f]{40}|[0-9a-f]{64}) -->$/;
const REASON = /^- Reason: `([a-z-]+)`$/m;
const clearedMarker = (reason, head) => `<!-- pr-pipeline needs-human-cleared reason=${reason} head=${head} -->`;
const CLEARED = /^<!-- pr-pipeline needs-human-cleared reason=[a-z,-]+ head=[0-9a-f]{40} -->$/;
// babysit_merge.py's verifier-refusal escalation, reasons comment and
// shared-copy escalation: the marker on the first or second line, the next
// line naming `at head `<sha>``.
Expand All @@ -81,39 +92,18 @@ const PR_MERGE_MARKERS = Object.freeze([
"<!-- babysit-merge-shared-copy-escalation -->",
]);
const AT_HEAD = / at head `([^`]*)`/g;
// A review lane's hold comment opens a line with this marker. The lanes post
// as claude[bot], or as github-actions[bot] with the lane's footer as the last
// non-blank line (address-feedback.mjs's lane comment rule).
const REVIEW_HOLD = "<!-- claude-lane-hold lane=";
const LANE_FOOTER = /^`?claude-lane: (?:claude-review|claude-security-review)`?$/;
const HEAD_MOVED = "head-moved";
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const SHA = /^[0-9a-f]{40}$/;
// The compare API lists at most 300 files, the PR files API at most 3000.
const COMPARE_CAP = 300;
const FILES_CAP = 3000;
// report-escalation.sh writes the label, then the comment, retrying the
// comment for up to 15 seconds; a minute covers that (judgment).
const PAIRED_MS = 60_000;
// Each clear lets the lanes run on the PR again (judgment).
export const CLEARS_PER_RUN = 5;

const escapeCommand = (text) => String(text).replaceAll(/[\r\n%]/g, " ");
const reportError = (number, error) =>
console.log(`::error title=clear-escalations::#${number}: ${escapeCommand(error?.message ?? error)}`);
const lines = (body) => String(body ?? "").split("\n").map((line) => line.replace(/\r$/, ""));
const same = (a, b) => String(a ?? "").toLowerCase() === String(b).toLowerCase();
const isApp = (user, bot) => user?.login === bot && user?.type === "Bot";

// The REVIEW_HOLD marker line of a review lane's hold comment, or undefined.
function laneHold(item) {
const body = lines(item.body).map((line) => line.trim());
const marker = body.find((line) => line.startsWith(REVIEW_HOLD));
const lane =
isApp(item.user, "claude[bot]") ||
(isApp(item.user, "github-actions[bot]") && LANE_FOOTER.test(body.findLast((line) => line !== "") ?? ""));
return item.event === "commented" && lane ? marker : undefined;
}

// Why each reason no longer holds at `pull`'s head, or null while it holds.
const CHECKS = Object.freeze({
Expand Down Expand Up @@ -199,32 +189,20 @@ function spoken(item) {
// What to clear on `number`, or { reason } saying why nothing is.
async function verdict({ github, repo, number, label, bot, mainSha }) {
const timeline = await paginate(github, `/repos/${repo}/issues/${number}/timeline`);
const labeled = timeline.filter((item) => item.event === "labeled" && same(item.label?.name, label)).at(-1);
const { labeled, since, lifted } = labelHistory(timeline, label);
if (!isApp(labeled?.actor, bot)) {
return { reason: `${label} was last added by ${labeled?.actor?.login ?? "no one on the timeline"}, not the lanes App` };
}
const since = Date.parse(labeled.created_at);
// A comment's writes are its creation and its last edit; a tie, or a time
// that does not parse, keeps the label.
const lifted = Math.max(
-Infinity,
...timeline
.filter((item) => item.event === "unlabeled" && same(item.label?.name, label))
.map((item) => Date.parse(item.created_at))
.filter((time) => time <= since),
);
const held = timeline.find(
(item) => laneHold(item) !== undefined && !(Math.max(Date.parse(item.created_at), Date.parse(item.updated_at ?? item.created_at)) < lifted),
);
const held = openReviewHold(timeline, lifted);
if (held !== undefined) {
return { reason: `a review lane holds it: ${laneHold(held).slice(0, 120)}` };
}
// Every escalation since the label went on: a later one posts no new label
// event, so each reason it recorded must have cleared.
const appComments = timeline.filter((item) => item.event === "commented" && isApp(item.user, bot));
const escalations = appComments
.map((item) => ({ body: lines(item.body), at: Date.parse(item.created_at) }))
.filter(({ body, at }) => body[0] === ESCALATION && RESUME_KEY.test(body[1] ?? "") && at >= since);
const escalations = escalationsSince(appComments, since);
const merged = escalations.length === 0 && pairedWithPrMerge(appComments, since) ? prMergeEscalations(appComments) : [];
let reasons = [];
if (merged.length === 0) {
Expand All @@ -243,10 +221,7 @@ async function verdict({ github, repo, number, label, bot, mainSha }) {
// label that is not the escalation being judged or this script's own keeps
// it, so a marker this script does not know fails closed.
const known = merged.length > 0 ? (line) => PR_MERGE_MARKERS.includes(line) : (line) => line === ESCALATION || RESUME_KEY.test(line);
const foreign = appComments
.filter((item) => !(Date.parse(item.created_at) < since) || !(Date.parse(item.updated_at ?? item.created_at) < since))
.flatMap((item) => lines(item.body).filter((line) => line.includes("<!--")))
.find((line) => !known(line) && !CLEARED.test(line));
const foreign = foreignMarker(appComments, since, known);
if (foreign !== undefined) {
return { reason: `a lanes App comment since ${label} went on carries another marker: ${foreign.slice(0, 120)}` };
}
Expand Down
29 changes: 23 additions & 6 deletions .github/scripts/pr-unblock/address-feedback.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
// record the reply job checks the replies against (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.
// awaiting-human when there are none, unless a lane escalation put
// the label on and no review lane holds the PR (gate-source.mjs).
//
// 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
Expand All @@ -29,12 +30,13 @@ import { appendFileSync, lstatSync, readdirSync, readFileSync, writeFileSync } f
import { join } from "node:path";
import { pathToFileURL } from "node:url";

import { commentItems, gateSource, listLabelEvents, ROUND_MARKER } from "./gate-source.mjs";
import { isProtectedPath, namedInScope } from "./thread-scope.mjs";

// The round cap is the config's `loop.review-rounds`, passed in as
// REVIEW_ROUNDS; the resolver's default is the fallback.
export const DEFAULT_MAX_ROUNDS = 2;
export const ROUND_MARKER = "<!-- pr-unblock:address-feedback-round -->";
export { ROUND_MARKER };
// github-actions[bot], the author of the review lanes' reviewed-head markers.
export const MARKER_AUTHOR_ID = 41898282;
const REVIEWED_HEAD =
Expand Down Expand Up @@ -422,8 +424,18 @@ export async function select({
api.listThreads(pr),
]);
// The human-gated label holds the PR except for threads a trusted person
// with write access asked the lane to fix.
const gated = (pull.labels ?? []).some((label) => label.name === humanGatedLabel);
// 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.
const labeled = (pull.labels ?? []).some((label) => label.name === humanGatedLabel);
const escalated =
labeled &&
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;
const fixers = await reviewFixers({ reviewComments, trustedHumans, permission: api.getPermission });
const kept = keepThreads({
threads,
Expand Down Expand Up @@ -481,7 +493,10 @@ export async function select({
return {
decision: "proceed",
rounds,
fixRequested: gated,
// 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.
fixRequested: labeled,
threads: worked,
file: { version: 1, repository, pr, head_sha: headSha, threads: worked },
};
Expand Down Expand Up @@ -592,6 +607,7 @@ export function createGitHubApi({ token, repository, apiUrl, graphqlUrl, fetch =
const repo = `${apiUrl}/repos/${owner}/${name}`;
return {
getPull: async (pr) => (await request(`${repo}/pulls/${pr}`)).data,
listLabelEvents: (pr) => listLabelEvents({ graphql, repository, number: pr }),
// Metadata read, which every token holds. A 404 (not a collaborator) is none.
async getPermission(login) {
const url = `${repo}/collaborators/${encodeURIComponent(login)}/permission`;
Expand Down Expand Up @@ -831,7 +847,8 @@ export async function main(argv, env, { api, log = (line) => console.log(line) }
}
output("proceed", String(result.decision === "proceed"));
// The write runner drops the human-gated label from the hold list only
// when this is true: every kept thread carries a write-access fix request.
// when this is true: every kept thread carries a write-access fix request,
// or a lane escalation put the label on.
output("fix-requested", String(result.fixRequested === true));
output("skip-reason", result.decision === "skip" ? result.skipReason : result.decision === "escalate" ? "awaiting-human" : "");
output("escalation-reason", result.escalationReason ?? "");
Expand Down
50 changes: 50 additions & 0 deletions .github/scripts/pr-unblock/address-feedback.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ function fakeApi(overrides = {}) {
listReviews: async () => overrides.reviews ?? [{ id: 10, user: { id: REVIEWER }, commit_id: HEAD }],
listReviewComments: async () => overrides.reviewComments ?? [comment(100, REVIEWER)],
listThreads: async () => overrides.threads ?? [thread("PRRT_1", [100])],
async listLabelEvents() {
overrides.labelEventReads?.push(1);
return overrides.labelEvents ?? [];
},
listPullCommits: async () => Object.keys(overrides.checks ?? { [HEAD]: [] }).map((sha) => ({ sha })),
listCheckRuns: async (sha) => (overrides.checks ?? {})[sha] ?? [],
createIssueComment: async (pr, body) => posted.push({ pr, body }),
Expand Down Expand Up @@ -238,6 +242,52 @@ for (const [label, overrides] of [
});
}

// Where needs-human came from (gate-source.mjs): a lane escalation is the
// lanes App's label, then its marked comment; a review-lane hold is the review
// lane's hold comment.
const APP_USER = { login: "melodic-automation-lanes[bot]", id: APP, type: "Bot" };
const LANE_ESCALATION = {
id: 2,
user: APP_USER,
body: `<!-- work-items:escalation lane=pr-unblock kind=escalated -->\n<!-- pr-unblock resume-key=${OLD_HEAD} -->\nThe pr-unblock lane stopped.`,
created_at: "2026-10-18T10:00:05Z",
updated_at: "2026-10-18T10:00:05Z",
};
const REVIEW_HOLD = {
id: 3,
user: { login: "github-actions[bot]", id: ACTIONS_BOT, type: "Bot" },
body: `<!-- claude-lane-hold lane=claude-review sha=${HEAD} criteria=cost -->\nHeld.\n\nclaude-lane: claude-review`,
created_at: "2026-10-18T09:00:00Z",
updated_at: "2026-10-18T09:00:00Z",
};
const APP_LABELED = [{ event: "labeled", actor: APP_USER, label: { name: "needs-human" }, created_at: "2026-10-18T10:00:00Z" }];
const reviewedHead = { id: 1, user: { id: ACTIONS_BOT }, body: marker("claude-review", HEAD) };

test("on a needs-human PR a lane escalation gated, a reviewer-bot thread proceeds and releases the label hold", async () => {
const result = await run(fakeApi({ labels: ["needs-human"], issueComments: [reviewedHead, LANE_ESCALATION], labelEvents: APP_LABELED }));
assert.equal(result.decision, "proceed");
assert.equal(result.fixRequested, true);
assert.equal(result.file.threads[0].root_comment_id, 100);
});

for (const [label, issueComments] of [
["a review-lane hold", [reviewedHead, REVIEW_HOLD]],
["a lane escalation beside a review-lane hold", [reviewedHead, REVIEW_HOLD, LANE_ESCALATION]],
["an escalation marker with no resume-key line", [reviewedHead, { ...LANE_ESCALATION, body: "<!-- work-items:escalation lane=pr-unblock kind=escalated -->\nStopped." }]],
]) {
test(`on a needs-human PR, a reviewer-bot thread under ${label} holds the PR (awaiting-human)`, async () => {
const result = await run(fakeApi({ labels: ["needs-human"], issueComments, labelEvents: APP_LABELED }));
assert.equal(result.decision, "skip");
assert.equal(result.skipReason, "awaiting-human");
});
}

test("without needs-human no label event is read", async () => {
const labelEventReads = [];
assert.equal((await run(fakeApi({ labelEventReads }))).decision, "proceed");
assert.deepEqual(labelEventReads, []);
});

test("without needs-human nothing changes and no permission is read", async () => {
const api = fakeApi(escalated());
const result = await run(api);
Expand Down
25 changes: 20 additions & 5 deletions .github/scripts/pr-unblock/feedback-dispatch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@
// every comment is the reviewer bot's, a trusted person with write access
// 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, only a thread where a trusted person with write
// access asked the lane to fix it (isFixRequest);
// 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
// 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
// usage limit stopped stops counting once its limit resets, and one that
// started before the head's newest pr-unblock / publish-feedback check run
Expand Down Expand Up @@ -60,6 +62,7 @@ import { pathToFileURL } from "node:url";

import { commitPulls } from "../../actions/check-trusted-trigger/check-trusted-trigger.mjs";
import { fixRequesters, humanGatedLabelFrom, threadAuthors, trustedHumansFrom } from "./address-feedback.mjs";
import { commentItems, gateSource, listLabelEvents } from "./gate-source.mjs";
import { emptyRecord, isHandled, markHandled, readRecord, writeRecord } from "./dispatch-record.mjs";
import { anyHandles, limitResetsAt, permanentlyHandles } from "./limit-stop.mjs";
import { budgetedFetch } from "./request-budget.mjs";
Expand Down Expand Up @@ -227,8 +230,19 @@ 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.
const gated = (pull.labels ?? []).some((label) => label.name === humanGatedLabel);
// 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:
// 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 gated = labeled && gate.source !== "lane-escalation";
const fixers = await fixRequesters({
comments: threads.flatMap((thread) =>
thread.comments.nodes.map((node) => ({ authorId: node.author?.databaseId, login: node.author?.login, body: node.body })),
Expand All @@ -240,7 +254,7 @@ export async function dispatchFeedback({
if (!hasWorkableThread(threads, { reviewerId: settings.reviewerId, appId: appSenderId, trustedHumans, requesters, fixers })) {
return skip(
gated
? `#${number} is labeled ${humanGatedLabel} and has no thread a trusted person with write access asked the lane to fix`
? `#${number} is labeled ${humanGatedLabel} (${gate.source}: ${gate.reason}) and has no thread a trusted person with write access asked the lane to fix`
: `#${number} has no unresolved thread left by the reviewer bot alone, asked to be fixed, or answered by a trusted person`,
);
}
Expand Down Expand Up @@ -393,6 +407,7 @@ export function createGitHubApi({ token, repository, apiUrl, graphqlUrl, fetch =
}
throw new Error(`#${number} has more than 5000 comments`);
},
listLabelEvents: (number) => listLabelEvents({ graphql, repository, number }),
async listReviewThreads(number) {
const threads = [];
let after = null;
Expand Down
Loading
Loading