From 060e17999d737b4d2da422dc9e4cf907108a3d2c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:41:03 -0400 Subject: [PATCH 01/15] feat(pr-pipeline): arm native auto-merge for lane pull requests The pr-merge lane's new enable-auto-merge activity turns on GitHub's native auto-merge (squash, expectedHeadOid) for each open lanes App pull request into main that closes an issue and carries no hold, and turns it off on an armed one that now carries a Hold: line, do-not-merge or the human-gated label. pr-merge.yml runs it in its own job when the issue lane finishes, on a listed human's PR edit, and on the schedule. Co-Authored-By: Claude Opus 5.5 --- ...eat-enable-auto-merge-activity-4e7a2c91.md | 7 + .../scripts/pr-merge/enable-auto-merge.mjs | 186 +++++++++++++++++ .../pr-merge/enable-auto-merge.test.mjs | 192 ++++++++++++++++++ .../fixtures/enable-auto-merge-tracker.json | 7 + .github/scripts/pr-merge/workflow.test.mjs | 23 +++ .github/workflows/pr-merge.yml | 140 +++++++++++++ docs/conventions/pr-pipeline.yaml | 16 ++ docs/conventions/pr-pipeline/README.md | 16 ++ 8 files changed, 587 insertions(+) create mode 100644 .changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md create mode 100644 .github/scripts/pr-merge/enable-auto-merge.mjs create mode 100644 .github/scripts/pr-merge/enable-auto-merge.test.mjs create mode 100644 .github/scripts/pr-merge/fixtures/enable-auto-merge-tracker.json diff --git a/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md b/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md new file mode 100644 index 0000000000..faec8644b1 --- /dev/null +++ b/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Added + +- The pr-merge lane's `enable-auto-merge` activity (effect `merge`) turns on GitHub's native auto-merge, squash at the head it read, for each open lanes App pull request into main that opens with `Closes #` and carries no hold (a `Hold:` line or "do not merge" in its body, `do-not-merge`, the human-gated label), and turns it off on an armed one that now carries a hold. `pr-merge.yml` runs it in its own job, with its own lanes App token, when the issue lane finishes (a new `workflow_run` trigger the merge job ignores), on a listed human's edit, unlabel or ready, and on the schedule. While `merge.report-only` is true it writes nothing ([README.md, Native auto-merge](https://github.com/melodic-software/claude-code-plugins/blob/main/docs/conventions/pr-pipeline/README.md#native-auto-merge)). diff --git a/.github/scripts/pr-merge/enable-auto-merge.mjs b/.github/scripts/pr-merge/enable-auto-merge.mjs new file mode 100644 index 0000000000..da581b5fd0 --- /dev/null +++ b/.github/scripts/pr-merge/enable-auto-merge.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +// enable-auto-merge.mjs: turns on GitHub's native auto-merge for the lanes +// App's open pull requests into main, and turns it off on one that now carries +// a hold. No model. pr-merge.yml's enable-auto-merge job runs it when the issue +// lane finishes, when a listed human edits, unlabels or readies a pull +// request, and on the schedule, which arms any the other events missed. +// +// A pull request is armed only when all of these hold: +// - The lanes App opened it (merge.agent-identity), into main, from a branch +// of this repository, and it is not a draft. +// - Its body opens with `Closes #`: a `Refs:` pull request is merged +// by a person (report-outcome.mjs). +// - It carries no hold: no `do-not-merge` and no human-gated label (the +// tracker's role label, `needs-human` by default), and no body hold, the +// gate's own rule (babysit_merge_policy.BODY_MERGE_HOLD_RE): a line opening +// with `Hold:`, or "do not merge" anywhere. +// Arming passes the head just read as expectedHeadOid, so a push in between +// fails the arm rather than arming a head nobody read. An armed pull request +// that fails any rule is disarmed. While merge.report-only is true it reports +// each decision and writes nothing. +// +// Environment: +// GH_TOKEN the lane App token (contents and pull-requests write) +// GITHUB_REPOSITORY owner/repo +// KILL_SWITCH `proceed` from the kill-switch step; anything else stops +// REPORT_ONLY `false` writes; anything else reports only +// LANES_BOT_LOGIN merge.agent-identity, an App bot login +// TRACKER_CONFIG the base .work-item-tracker.json, for the human-gated +// label (default .base/.work-item-tracker.json) +// GITHUB_API_URL +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +import { createGitHub } from "../../actions/check-trusted-trigger/check-trusted-trigger.mjs"; +import { humanGatedLabelFrom } from "../pr-unblock/address-feedback.mjs"; + +const REPO = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/; +const SHA = /^[0-9a-f]{40}$/; +const BOT_LOGIN = /^([a-z0-9][a-z0-9-]*)\[bot\]$/; +// babysit_merge_policy.MERGE_HOLD_LABELS, plus the human-gated label. +const HOLD_LABELS = Object.freeze(["do-not-merge"]); +// babysit_merge_policy.BODY_MERGE_HOLD_RE; enable-auto-merge.test.mjs checks the two +// agree on every body it tries. +export const BODY_HOLD = /\bdo(?:n['’]?t| not)\s+merge\b|(?:^|\r)[^\S\r\n]*hold:/im; +const CLOSES = /^Closes #[1-9][0-9]*\r?$/; +// 100 pull requests a page; more than this many open is a listing the run +// stops on rather than one it reads only part of. +const MAX_PAGES = 10; + +export const PULLS_QUERY = `query($owner: String!, $name: String!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequests(states: OPEN, baseRefName: "main", first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + id number isDraft isCrossRepository headRefOid body + author { __typename login } + labels(first: 100) { totalCount nodes { name } } + autoMergeRequest { enabledAt } + } + } + } +}`; +export const ARM_MUTATION = `mutation($id: ID!, $head: GitObjectID!) { + enablePullRequestAutoMerge(input: { pullRequestId: $id, mergeMethod: SQUASH, expectedHeadOid: $head }) { + pullRequest { autoMergeRequest { enabledAt } } + } +}`; +export const DISARM_MUTATION = `mutation($id: ID!) { + disablePullRequestAutoMerge(input: { pullRequestId: $id }) { clientMutationId } +}`; + +const escapeCommand = (text) => String(text).replaceAll(/[\r\n%]/g, " "); + +// Why `pull` must not be armed, or null when it may be. +export function holdReason(pull, humanGated) { + const labels = pull.labels?.nodes ?? []; + if (!(pull.labels?.totalCount <= labels.length)) { + return "its labels could not all be read"; + } + const names = new Set(labels.map((label) => String(label?.name).toLowerCase())); + const label = [...HOLD_LABELS, humanGated].find((name) => names.has(name.toLowerCase())); + if (label !== undefined) { + return `it carries ${label}`; + } + const body = String(pull.body ?? ""); + if (BODY_HOLD.test(body)) { + return "its body holds it"; + } + if (!CLOSES.test(body.split("\n")[0])) { + return "its body does not open with Closes #"; + } + return null; +} + +// What to do with one listed pull request: { action: arm | disarm | none, reason }. +export function decide(pull, { bot, humanGated }) { + if (pull.author?.__typename !== "Bot" || pull.author.login !== bot) { + return { action: "none", reason: "not the lanes App's" }; + } + const armed = pull.autoMergeRequest != null; + const held = + pull.isDraft !== false + ? "it is a draft" + : pull.isCrossRepository !== false + ? "it is from another repository" + : !SHA.test(String(pull.headRefOid)) + ? "its head is not a commit SHA" + : holdReason(pull, humanGated); + if (held !== null) { + return { action: armed ? "disarm" : "none", reason: held }; + } + return armed ? { action: "none", reason: "already armed" } : { action: "arm", reason: "no hold" }; +} + +async function listPulls(github, owner, name) { + const pulls = []; + let after = null; + for (let page = 0; page < MAX_PAGES; page += 1) { + const data = (await github("POST", "/graphql", { query: PULLS_QUERY, variables: { owner, name, after } }))?.data; + const connection = data?.repository?.pullRequests; + if (!Array.isArray(connection?.nodes)) { + throw new Error("the open pull request listing did not read"); + } + pulls.push(...connection.nodes); + if (!connection.pageInfo?.hasNextPage) { + return pulls; + } + after = connection.pageInfo.endCursor; + } + throw new Error(`more than ${MAX_PAGES * 100} open pull requests into main`); +} + +export async function main({ env = process.env, github } = {}) { + try { + const repo = REPO.exec(env.GITHUB_REPOSITORY ?? ""); + if (!repo) { + throw new Error("GITHUB_REPOSITORY is not owner/repo"); + } + if (env.KILL_SWITCH !== "proceed") { + console.log("enable-auto-merge: the lanes kill switch stops this run; nothing read or armed"); + return 0; + } + const slug = BOT_LOGIN.exec(env.LANES_BOT_LOGIN ?? "")?.[1]; + if (slug === undefined) { + throw new Error("LANES_BOT_LOGIN is not an App bot login"); + } + const write = env.REPORT_ONLY === "false"; + const humanGated = humanGatedLabelFrom(env.TRACKER_CONFIG || ".base/.work-item-tracker.json"); + github ??= createGitHub({ token: env.GH_TOKEN, apiUrl: env.GITHUB_API_URL || undefined }); + const pulls = await listPulls(github, repo[1], repo[2]); + let failed = 0; + for (const pull of pulls) { + const { action, reason } = decide(pull, { bot: slug, humanGated }); + if (action === "none") { + if (reason !== "not the lanes App's") { + console.log(`enable-auto-merge: #${pull.number} left: ${reason}`); + } + continue; + } + if (!write) { + console.log(`enable-auto-merge: #${pull.number} would ${action} (report-only): ${reason}`); + continue; + } + try { + if (action === "arm") { + await github("POST", "/graphql", { query: ARM_MUTATION, variables: { id: pull.id, head: pull.headRefOid } }); + console.log(`enable-auto-merge: #${pull.number} armed at ${pull.headRefOid}`); + } else { + await github("POST", "/graphql", { query: DISARM_MUTATION, variables: { id: pull.id } }); + console.log(`enable-auto-merge: #${pull.number} disarmed: ${reason}`); + } + } catch (error) { + failed += 1; + console.log(`::error title=enable-auto-merge::#${pull.number} ${action}: ${escapeCommand(error?.message ?? error)}`); + } + } + return failed === 0 ? 0 : 1; + } catch (error) { + console.log(`::error title=enable-auto-merge::${escapeCommand(error?.message ?? error)}`); + return 1; + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + process.exitCode = await main(); +} diff --git a/.github/scripts/pr-merge/enable-auto-merge.test.mjs b/.github/scripts/pr-merge/enable-auto-merge.test.mjs new file mode 100644 index 0000000000..5f493e7e21 --- /dev/null +++ b/.github/scripts/pr-merge/enable-auto-merge.test.mjs @@ -0,0 +1,192 @@ +// Expected values come from #9276's plan item 8 and the owner answers on it: +// a lanes App pull request is armed for native auto-merge (squash, at the head +// read) with no session, and is never armed, or is disarmed, while it carries +// a `Hold:` line, do-not-merge or the human-gated label. A Refs pull request +// is merged by a person (report-outcome.mjs), so it is never armed. The body +// hold is the gate's own rule, babysit_merge_policy.BODY_MERGE_HOLD_RE, so the +// parity test reads its verdicts from Python. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +import { ARM_MUTATION, BODY_HOLD, DISARM_MUTATION, PULLS_QUERY, decide, main } from "./enable-auto-merge.mjs"; + +const REPO = "melodic-software/claude-code-plugins"; +const LANES = { __typename: "Bot", login: "melodic-automation-lanes" }; +const HEAD = "b".repeat(40); +const ENV = { + GITHUB_REPOSITORY: REPO, + KILL_SWITCH: "proceed", + REPORT_ONLY: "false", + LANES_BOT_LOGIN: "melodic-automation-lanes[bot]", + TRACKER_CONFIG: "/nonexistent/.work-item-tracker.json", +}; + +const pull = (number, overrides = {}) => ({ + id: `PR_${number}`, + number, + isDraft: false, + isCrossRepository: false, + headRefOid: HEAD, + body: `Closes #${number + 1000}\n\n## Summary\n\nDone.`, + author: LANES, + labels: { totalCount: 0, nodes: [] }, + autoMergeRequest: null, + ...overrides, +}); +const labels = (...names) => ({ totalCount: names.length, nodes: names.map((name) => ({ name })) }); + +function world(pulls, { failArm = false } = {}) { + const calls = []; + const github = async (method, path, body) => { + calls.push({ method, path, body }); + assert.equal(`${method} ${path}`, "POST /graphql"); + if (body.query === PULLS_QUERY) { + assert.deepEqual(body.variables, { owner: "melodic-software", name: "claude-code-plugins", after: null }); + return { data: { repository: { pullRequests: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: pulls } } } }; + } + if (body.query === ARM_MUTATION && failArm) { + throw new Error("POST /graphql (GraphQL errors) failed with status 200"); + } + return { data: {} }; + }; + const writes = () => + calls + .filter(({ body }) => body.query !== PULLS_QUERY) + .map(({ body }) => ({ mutation: body.query === ARM_MUTATION ? "arm" : body.query === DISARM_MUTATION ? "disarm" : "?", ...body.variables })); + return { github, writes }; +} + +async function run(pulls, env = {}, options = {}) { + const { github, writes } = world(pulls, options); + const lines = []; + const log = console.log; + console.log = (line) => lines.push(line); + try { + const code = await main({ env: { ...ENV, ...env }, github }); + return { code, writes: writes(), lines }; + } finally { + console.log = log; + } +} + +test("a lanes App pull request with no hold is armed, squash, at the head it read", async () => { + const { code, writes } = await run([pull(7)]); + assert.equal(code, 0); + assert.deepEqual(writes, [{ mutation: "arm", id: "PR_7", head: HEAD }]); + assert.match(ARM_MUTATION, /mergeMethod: SQUASH, expectedHeadOid: \$head/); +}); + +test("a Hold: line, do-not-merge or the human-gated label leaves it unarmed", async () => { + const { code, writes } = await run([ + pull(1, { body: "Closes #5\n\nHold: a human merges this\n" }), + pull(2, { body: "Closes #5\r\n hold: wait for the canary" }), + pull(3, { labels: labels("do-not-merge") }), + pull(4, { labels: labels("Needs-Human") }), + pull(5, { body: "Closes #5\n\nPlease do not merge yet." }), + ]); + assert.equal(code, 0); + assert.deepEqual(writes, []); +}); + +test("the human-gated label comes from the tracker config", async () => { + const config = fileURLToPath(new URL("./fixtures/enable-auto-merge-tracker.json", import.meta.url)); + const { writes } = await run([pull(1, { labels: labels("human-gated") }), pull(2, { labels: labels("needs-human") })], { TRACKER_CONFIG: config }); + assert.deepEqual(writes, [{ mutation: "arm", id: "PR_2", head: HEAD }]); +}); + +test("an armed pull request that now holds is disarmed", async () => { + const armed = { enabledAt: "2026-10-23T10:00:00Z" }; + const { code, writes } = await run([ + pull(1, { autoMergeRequest: armed, body: "Closes #5\n\nHold: a human merges this" }), + pull(2, { autoMergeRequest: armed, labels: labels("needs-human") }), + pull(3, { autoMergeRequest: armed }), + ]); + assert.equal(code, 0); + assert.deepEqual(writes, [ + { mutation: "disarm", id: "PR_1" }, + { mutation: "disarm", id: "PR_2" }, + ]); +}); + +test("only the lanes App's ready Closes pull requests from this repository are armed", async () => { + const { writes } = await run([ + pull(1, { author: { __typename: "User", login: "kyle-sexton" } }), + pull(2, { author: { __typename: "Bot", login: "dependabot" } }), + pull(3, { author: { __typename: "User", login: "melodic-automation-lanes" } }), + pull(4, { isDraft: true }), + pull(5, { isCrossRepository: true }), + pull(6, { body: "Refs: #12\n\n## Summary" }), + pull(7, { body: "## Summary\n\nCloses #12" }), + pull(8, { headRefOid: "abc" }), + pull(9, { labels: { totalCount: 101, nodes: [] } }), + ]); + assert.deepEqual(writes, []); +}); + +test("report-only arms and disarms nothing", async () => { + const { code, writes, lines } = await run([pull(1), pull(2, { autoMergeRequest: {}, labels: labels("do-not-merge") })], { REPORT_ONLY: "true" }); + assert.equal(code, 0); + assert.deepEqual(writes, []); + assert.ok(lines.some((line) => /#1 would arm \(report-only\)/.test(line))); + assert.ok(lines.some((line) => /#2 would disarm \(report-only\)/.test(line))); +}); + +test("the kill switch stops it before any read", async () => { + const { code, writes } = await run([pull(1)], { KILL_SWITCH: "" }); + assert.equal(code, 0); + assert.deepEqual(writes, []); +}); + +test("a failed arm fails the run and still reaches the next pull request", async () => { + const { code, writes, lines } = await run([pull(1), pull(2, { autoMergeRequest: {}, labels: labels("do-not-merge") })], {}, { failArm: true }); + assert.equal(code, 1); + assert.deepEqual(writes, [ + { mutation: "arm", id: "PR_1", head: HEAD }, + { mutation: "disarm", id: "PR_2" }, + ]); + assert.ok(lines.some((line) => line.startsWith("::error title=enable-auto-merge::#1 arm"))); +}); + +test("a bad bot login or repository fails red", async () => { + assert.equal((await run([pull(1)], { LANES_BOT_LOGIN: "kyle-sexton" })).code, 1); + assert.equal((await run([pull(1)], { GITHUB_REPOSITORY: "nope" })).code, 1); +}); + +test("decide leaves a pull request with no labels read alone", () => { + assert.deepEqual(decide(pull(1, { labels: null }), { bot: LANES.login, humanGated: "needs-human" }), { + action: "none", + reason: "its labels could not all be read", + }); +}); + +test("the body hold agrees with the gate's BODY_MERGE_HOLD_RE", () => { + const bodies = [ + "Hold: a human merges this", + "Closes #1\n\nHold: x", + "Closes #1\r\n HOLD: x", + "Closes #1\rhold: x", + "Closes #1\n\nthe hold: is not at the line start", + "Closes #1\n\nDon't merge", + "Closes #1\n\ndon’t merge this", + "Closes #1\n\nDo NOT\nmerge", + "Closes #1\n\ndonut merge", + "Closes #1\n\nwhat to do: merge it", + "Closes #1\n\nnothing here", + "", + ]; + const scripts = fileURLToPath(new URL("../../../plugins/source-control/skills/babysit-prs/scripts", import.meta.url)); + const python = spawnSync( + "python3", + ["-c", "import json,sys\nfrom babysit_merge_policy import BODY_MERGE_HOLD_RE as R\nprint(json.dumps([bool(R.search(b)) for b in json.load(sys.stdin)]))"], + { cwd: scripts, input: JSON.stringify(bodies), encoding: "utf8" }, + ); + assert.equal(python.status, 0, python.stderr); + const expected = JSON.parse(python.stdout); + assert.deepEqual( + bodies.map((body) => BODY_HOLD.test(body)), + expected, + ); + assert.ok(expected.includes(true) && expected.includes(false)); +}); diff --git a/.github/scripts/pr-merge/fixtures/enable-auto-merge-tracker.json b/.github/scripts/pr-merge/fixtures/enable-auto-merge-tracker.json new file mode 100644 index 0000000000..b778c55da0 --- /dev/null +++ b/.github/scripts/pr-merge/fixtures/enable-auto-merge-tracker.json @@ -0,0 +1,7 @@ +{ + "config": { + "role_labels": { + "human-gated": "human-gated" + } + } +} diff --git a/.github/scripts/pr-merge/workflow.test.mjs b/.github/scripts/pr-merge/workflow.test.mjs index 528909808e..30a72d9fba 100644 --- a/.github/scripts/pr-merge/workflow.test.mjs +++ b/.github/scripts/pr-merge/workflow.test.mjs @@ -146,3 +146,26 @@ test("pr-require-checks.yml never cancels a dispatched or scheduled run", () => const group = /\nconcurrency:\n {2}group: >-\n([\s\S]*?)\n {2}cancel-in-progress:/.exec(checks)[1]; assert.match(group, /github\.event\.pull_request\.number \|\| github\.run_id/); }); + +// #9276 plan item 8: the lanes App token arms a lane pull request as soon as +// the issue lane finishes, in pr-merge.yml, the one caller the token broker +// mints `merge` for; the merge job's gate never runs on that event. +test("the issue lane finishing wakes only the enable-auto-merge job", () => { + assert.match(merge, /\n {2}workflow_run: # zizmor: ignore\[dangerous-triggers\]\n {4}workflows: \[work-resolve-issue\]\n {4}types: \[completed\]\n/); + assert.match(job("merge"), /\n {6}&& github\.event_name != 'workflow_run'\n/); + const arm = job("enable-auto-merge"); + assert.doesNotMatch(/\n {4}if: >-\n([\s\S]*?)\n {4}[a-z-]+:/.exec(arm)[1], /workflow_run'\n/); + assert.match(arm, /\n {4}concurrency:\n {6}group: pr-merge-enable-auto-merge\n {6}queue: max\n/); +}); + +test("enable-auto-merge asks the broker for its own activity's merge token", () => { + const arm = job("enable-auto-merge"); + const steps = [...arm.matchAll(/\n {10}lane: (.*)\n {10}activity: (.*)\n/g)].map((m) => `${m[1]}/${m[2]}`); + assert.deepEqual(steps, ["pr-merge/enable-auto-merge", "pr-merge/enable-auto-merge"]); + assert.match(arm, /node \.base\/\.github\/scripts\/pr-merge\/enable-auto-merge\.mjs/); + const block = /\n {4}permissions:\n((?: {6}[a-z-]+: [a-z]+.*\n)+)/.exec(arm)[1]; + assert.deepEqual( + [...block.matchAll(/ {6}([a-z-]+): ([a-z]+)/g)].map((m) => `${m[1]}: ${m[2]}`), + ["contents: read", "id-token: write"], + ); +}); diff --git a/.github/workflows/pr-merge.yml b/.github/workflows/pr-merge.yml index e08fe0184f..768b730854 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -96,6 +96,14 @@ on: push: branches: [main] + # The issue lane finished, and may have opened a pull request: only the + # enable-auto-merge job runs on it, so the new pull request is armed at + # once rather than at the next scheduled run. The run is the main copy of + # this file and checks out no code from the issue run. + workflow_run: # zizmor: ignore[dangerous-triggers] + workflows: [work-resolve-issue] + types: [completed] + permissions: {} defaults: @@ -298,6 +306,7 @@ jobs: # out of the queue. if: >- github.ref == 'refs/heads/main' && github.event_name != 'push' + && github.event_name != 'workflow_run' && (github.event_name != 'pull_request_target' || github.event.pull_request.head.repo.full_name == github.repository) # One run at a time, and a running one is never cancelled: a cancelled run @@ -1130,6 +1139,137 @@ jobs: echo "::warning::the lane token was not revoked (DELETE $status); it expires within the hour" fi + # Turns on GitHub's native auto-merge for each open lanes App pull request + # into main that closes an issue and carries no hold, and turns it off on + # one that now carries a hold (a `Hold:` line, do-not-merge, the human-gated + # label). Every event is a wake-up: the script lists the pull requests + # itself. The issue lane finishing arms the pull request it opened; a listed + # human's edit, unlabel or ready disarms or arms at once; the schedule + # catches what the events missed, such as a run the lanes App started, which + # the broker refuses. Its own job and token: the broker mints one token per + # job. Rules: .github/scripts/pr-merge/enable-auto-merge.mjs. + enable-auto-merge: + # An issue run that skipped every job (a label other than agent-ready) + # opened nothing. + if: >- + github.ref == 'refs/heads/main' && github.event_name != 'push' + && (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion != 'skipped') + && (github.event_name != 'pull_request_target' + || github.event.pull_request.head.repo.full_name == github.repository) + # A pending run is never replaced: a cancelled run leaves a CANCELLED + # check on the pull request that woke it. + concurrency: + group: pr-merge-enable-auto-merge + queue: max + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read # check out main + id-token: write # prove to the token broker which workflow this is + steps: + - name: Check out the default branch tip + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/heads/main + path: .base + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .base/.node-version + package-manager-cache: false + + - name: Check the kill switch + id: kill + uses: ./.base/.github/actions/check-kill-switch # zizmor: ignore[self-repository] + with: + value: ${{ vars.CLAUDE_LANES_DISABLED }} + repo-value: ${{ vars.CLAUDE_LANES_REPO_DISABLED }} + + # The broker mints the merge token only for a listed human, or on the + # schedule; any other run stops here quietly, as the merge job's does. + - name: Check who started the run + id: who + if: steps.kill.outputs.proceed == 'true' + env: + EVENT: ${{ github.event_name }} + ACTOR_ID: ${{ github.actor_id }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + LIST: .base/.github/standards/trusted-actors/trusted-actors.json + run: | + set -euo pipefail + if [ "$EVENT" = schedule ]; then + echo "proceed=true" >>"$GITHUB_OUTPUT" + elif [[ $ACTOR_ID =~ ^[1-9][0-9]*$ ]] && + jq -e --argjson id "$ACTOR_ID" --arg login "$TRIGGERING_ACTOR" ' + [.actors[] | select(.kind == "human")] as $humans + | ($humans | any(.id == $id)) + and ($humans | any((.login | ascii_downcase) == ($login | ascii_downcase))) + ' "$LIST" >/dev/null; then + echo "proceed=true" >>"$GITHUB_OUTPUT" + else + echo "::notice::started by ${TRIGGERING_ACTOR:-an unknown account}, who is not a listed human; the next scheduled run arms what this one would" + echo "proceed=false" >>"$GITHUB_OUTPUT" + fi + + - name: Resolve the config + id: config + if: steps.who.outputs.proceed == 'true' + uses: ./.base/.github/actions/resolve-config # zizmor: ignore[self-repository] + with: + lane: pr-merge + activity: enable-auto-merge + base-path: .base + output-path: ${{ runner.temp }}/resolved-config.json + + - name: Request the lane token + id: lane-token + if: steps.config.outcome == 'success' + uses: ./.base/.github/actions/request-lane-token # zizmor: ignore[self-repository] + with: + broker-url: ${{ vars.LANES_BROKER_URL }} + audience: ${{ vars.LANES_BROKER_AUDIENCE }} + lane: pr-merge + activity: enable-auto-merge + effect: ${{ steps.config.outputs.effect }} + contents: ${{ steps.config.outputs.contents }} + pull-requests: ${{ steps.config.outputs.pull-requests }} + issues: ${{ steps.config.outputs.issues }} + superseded-ok: true + + # While merge.report-only is anything but false it reports each + # decision and writes nothing. + - name: Arm and disarm auto-merge + if: steps.lane-token.outputs.token != '' + timeout-minutes: 3 + env: + GH_TOKEN: ${{ steps.lane-token.outputs.token }} + KILL_SWITCH: ${{ steps.kill.outputs.proceed == 'true' && 'proceed' || '' }} + run: | + set -euo pipefail + config=.base/docs/conventions/pr-pipeline.yaml + REPORT_ONLY=$(yq '.merge."report-only"' "$config") + [ "$REPORT_ONLY" = null ] && REPORT_ONLY=false + LANES_BOT_LOGIN=$(yq '.merge."agent-identity" // ""' "$config") + export REPORT_ONLY LANES_BOT_LOGIN + node .base/.github/scripts/pr-merge/enable-auto-merge.mjs + + - name: Revoke the lane token + if: always() && steps.lane-token.outputs.token != '' + continue-on-error: true + env: + GH_TOKEN: ${{ steps.lane-token.outputs.token }} + run: | + status=$(printf 'Authorization: Bearer %s\n' "$GH_TOKEN" | + curl -sS -o /dev/null -w '%{http_code}' --max-time 30 -X DELETE -H @- \ + -H 'Accept: application/vnd.github+json' "$GITHUB_API_URL/installation/token" || true) + if [ "$status" = 204 ]; then + echo "the lane token is revoked" + else + echo "::warning::the lane token was not revoked (DELETE $status); it expires within the hour" + fi + # Starts the full CI run that verifies main. Starting a workflow needs # `actions: write`, which the lane App token does not have, so this job does # it with its own GitHub token, in two cases: diff --git a/docs/conventions/pr-pipeline.yaml b/docs/conventions/pr-pipeline.yaml index afb73151f4..a83fb24414 100644 --- a/docs/conventions/pr-pipeline.yaml +++ b/docs/conventions/pr-pipeline.yaml @@ -360,6 +360,20 @@ activities: # Reads PR bodies, labels and comments for holds. reads-untrusted: true inputs: [pr, head-sha] + enable-auto-merge: + # Turns on GitHub's native auto-merge (squash, at the head it read) for + # each open lanes App pull request into main that opens with `Closes #` + # and carries no hold (a `Hold:` line, do-not-merge, the human-gated + # label), and turns it off on an armed one that now carries a hold. No + # model. The enable-auto-merge job of .github/workflows/pr-merge.yml runs + # it when the issue lane finishes, on a listed human's PR edit, and on the + # schedule. + script: .github/scripts/pr-merge/enable-auto-merge.mjs + effect: merge + gating: advisory + # Reads PR bodies and labels for holds. + reads-untrusted: true + inputs: [pr, head-sha] label-unblocked: # When an issue closes (or, hourly, for each issue a listed human or a # merge closed in the last three hours), labels agent-ready each issue it @@ -451,6 +465,8 @@ lanes: stage: merge slots: - activity: merge-pr + # In its own job of pr-merge.yml, with its own token. + - activity: enable-auto-merge # Started by an issue closing and by an hourly sweep of recent closes # (.github/workflows/work-unblock.yml). work-unblock: diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index b7a3d1e880..2e7f8386b3 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -1003,6 +1003,22 @@ such as a demotion record, a ruleset, an edit to a comment older than the newest outside the keyed paths, delays re-gating by at most those limits; a skip never merges, enqueues or drops an escalation. +#### Native auto-merge + +The lane's `enable-auto-merge` activity, the `enable-auto-merge` job of `pr-merge.yml` with its +own lanes App token (`.github/scripts/pr-merge/enable-auto-merge.mjs`, no model), turns on GitHub's +native auto-merge (`enablePullRequestAutoMerge`, squash, `expectedHeadOid` the head it read) for +each open lanes App pull request into main from a branch of this repository that is not a draft, +opens with `Closes #`, and carries no hold: no `Hold:` line or "do not merge" in its body +(the gate's own rule) and no `do-not-merge` or human-gated label. It turns auto-merge off on an +armed one that now fails any of those. GitHub then merges the pull request through the merge queue +once `ci-status` passes. The issue lane finishing (`workflow_run`) wakes it, so a pull request is +armed right after report-outcome opens it; a listed human's edit, unlabel or ready wakes it too, so +adding a `Hold:` line disarms at once; and the 15-minute schedule arms or disarms what the events +missed. The token broker mints `merge` only for `pr-merge.yml`, and outside the schedule only for a +listed human's run, so an issue run the lanes App started is armed by the next scheduled run. While +`merge.report-only` is true it reports each decision and writes nothing. + ### Dependabot pull requests No Dependabot pull request waits on a human From f231b966bef3b4f3c3bf93184e067e6d4ddbaf3a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:42:48 -0400 Subject: [PATCH 02/15] chore(pr-pipeline): mark enable-auto-merge.mjs executable Co-Authored-By: Claude Opus 5.5 --- .github/scripts/pr-merge/enable-auto-merge.mjs | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 .github/scripts/pr-merge/enable-auto-merge.mjs diff --git a/.github/scripts/pr-merge/enable-auto-merge.mjs b/.github/scripts/pr-merge/enable-auto-merge.mjs old mode 100644 new mode 100755 From 838629c0fff1a8297cbbb26f5615bdb84956efe8 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:43:29 -0400 Subject: [PATCH 03/15] test(pr-pipeline): name the plan item, not an issue number, in test comments Co-Authored-By: Claude Opus 5.5 --- .github/scripts/pr-merge/enable-auto-merge.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/pr-merge/enable-auto-merge.test.mjs b/.github/scripts/pr-merge/enable-auto-merge.test.mjs index 5f493e7e21..e7ef8fc2f2 100644 --- a/.github/scripts/pr-merge/enable-auto-merge.test.mjs +++ b/.github/scripts/pr-merge/enable-auto-merge.test.mjs @@ -1,5 +1,5 @@ -// Expected values come from #9276's plan item 8 and the owner answers on it: -// a lanes App pull request is armed for native auto-merge (squash, at the head +// Expected values come from the native-merge plan's arm-merge item and the +// owner answers on it: a lanes App pull request is armed for native auto-merge (squash, at the head // read) with no session, and is never armed, or is disarmed, while it carries // a `Hold:` line, do-not-merge or the human-gated label. A Refs pull request // is merged by a person (report-outcome.mjs), so it is never armed. The body From ec4936f705682fcbe1ded7dfc491ee496ca50f19 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:56:17 -0400 Subject: [PATCH 04/15] test(pr-pipeline): pin report-outcome's PR body to the arm rule The arm rule reads the first body line as Closes #; feed it the body report-outcome builds, clean, with its hold line, and as Refs, so a change on either side that stops lane pull requests from arming fails a test. Also pin that enable-auto-merge never runs on a push to main. Co-Authored-By: Claude Opus 5.5 --- .../pr-merge/enable-auto-merge.test.mjs | 27 +++++++++++++++++++ .github/scripts/pr-merge/workflow.test.mjs | 5 +++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/.github/scripts/pr-merge/enable-auto-merge.test.mjs b/.github/scripts/pr-merge/enable-auto-merge.test.mjs index e7ef8fc2f2..d101bc048b 100644 --- a/.github/scripts/pr-merge/enable-auto-merge.test.mjs +++ b/.github/scripts/pr-merge/enable-auto-merge.test.mjs @@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { ARM_MUTATION, BODY_HOLD, DISARM_MUTATION, PULLS_QUERY, decide, main } from "./enable-auto-merge.mjs"; +import { decide as decideOutcome } from "../work-resolve-issue/report-outcome.mjs"; const REPO = "melodic-software/claude-code-plugins"; const LANES = { __typename: "Bot", login: "melodic-automation-lanes" }; @@ -190,3 +191,29 @@ test("the body hold agrees with the gate's BODY_MERGE_HOLD_RE", () => { ); assert.ok(expected.includes(true) && expected.includes(false)); }); + +// The body the issue lane opens its pull request with (report-outcome.mjs +// decide), so a change to either side that stops a clean lane pull request +// from arming, or arms a held one, fails here. +test("report-outcome's own pull request body arms, and its hold and Refs bodies do not", () => { + const plan = (overrides) => + decideOutcome({ + outcome: { exit: "done", reason: "", title: "docs(work-items): fix a typo", summary: "Fixed it." }, + issueNumber: 77, + issueLabels: ["agent-ready"], + branch: "work-resolve-issue/issue-77-123-1", + changedFiles: ["plugins/work-items/README.md"], + holdPaths: [".github/**"], + checks: { tip: HEAD, results: [] }, + ...overrides, + }); + const verdict = (overrides) => { + const opened = plan(overrides); + assert.equal(opened.action, "open-pr"); + return decide(pull(1, { body: opened.body }), { bot: "melodic-automation-lanes", humanGated: "needs-human" }); + }; + assert.deepEqual(verdict({}), { action: "arm", reason: "no hold" }); + assert.equal(verdict({ humanMerge: true }).action, "none"); + assert.equal(verdict({ changedFiles: [".github/workflows/x.yml"] }).action, "none"); + assert.equal(verdict({ outcome: { exit: "done", reason: "", title: "t", summary: "s", link: "refs" } }).action, "none"); +}); diff --git a/.github/scripts/pr-merge/workflow.test.mjs b/.github/scripts/pr-merge/workflow.test.mjs index 1bd2ca6d45..3efa228255 100644 --- a/.github/scripts/pr-merge/workflow.test.mjs +++ b/.github/scripts/pr-merge/workflow.test.mjs @@ -155,7 +155,10 @@ test("the issue lane finishing wakes only the enable-auto-merge job", () => { assert.match(merge, /\n {2}workflow_run: # zizmor: ignore\[dangerous-triggers\]\n {4}workflows: \[work-resolve-issue\]\n {4}types: \[completed\]\n/); assert.match(job("merge"), /\n {6}&& github\.event_name != 'workflow_run'\n/); const arm = job("enable-auto-merge"); - assert.doesNotMatch(/\n {4}if: >-\n([\s\S]*?)\n {4}[a-z-]+:/.exec(arm)[1], /workflow_run'\n/); + const armIf = /\n {4}if: >-\n([\s\S]*?)\n {4}[a-z-]+:/.exec(arm)[1]; + assert.doesNotMatch(armIf, /workflow_run'\n/); + // A push to main is a merge landing, never a new lane pull request. + assert.match(armIf, /github\.event_name != 'push'/); assert.match(arm, /\n {4}concurrency:\n {6}group: pr-merge-enable-auto-merge\n {6}queue: max\n/); }); From 689f2bb793cddd3cba24c8d23129a358571c33fd Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:17:42 -0400 Subject: [PATCH 05/15] fix(pr-pipeline): honor member vetoes and manual disables when arming Native auto-merge skips the gate's human veto, so the arm rule now reads each candidate's comments, reviews and inline comments and applies babysit_merge.human_veto_logins: a member's newest comment with text saying "do not merge" holds it (disarming an armed one), a later comment lifts it, and a discussion it cannot read whole is not armed. Auto-merge a person turned off is not turned back on. A missing merge.report-only now reaches the script as report-only instead of being rewritten to false, and the workflow test pins the workflow_run filter positively. Co-Authored-By: Claude Opus 5.5 --- ...eat-enable-auto-merge-activity-4e7a2c91.md | 2 +- .../scripts/pr-merge/enable-auto-merge.mjs | 85 ++++++++++++++++- .../pr-merge/enable-auto-merge.test.mjs | 93 ++++++++++++++++++- .github/scripts/pr-merge/workflow.test.mjs | 6 +- .github/workflows/pr-merge.yml | 1 - docs/conventions/pr-pipeline.yaml | 5 +- docs/conventions/pr-pipeline/README.md | 6 +- 7 files changed, 185 insertions(+), 13 deletions(-) diff --git a/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md b/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md index faec8644b1..9806e65e8d 100644 --- a/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md +++ b/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md @@ -4,4 +4,4 @@ bump: minor ### Added -- The pr-merge lane's `enable-auto-merge` activity (effect `merge`) turns on GitHub's native auto-merge, squash at the head it read, for each open lanes App pull request into main that opens with `Closes #` and carries no hold (a `Hold:` line or "do not merge" in its body, `do-not-merge`, the human-gated label), and turns it off on an armed one that now carries a hold. `pr-merge.yml` runs it in its own job, with its own lanes App token, when the issue lane finishes (a new `workflow_run` trigger the merge job ignores), on a listed human's edit, unlabel or ready, and on the schedule. While `merge.report-only` is true it writes nothing ([README.md, Native auto-merge](https://github.com/melodic-software/claude-code-plugins/blob/main/docs/conventions/pr-pipeline/README.md#native-auto-merge)). +- The pr-merge lane's `enable-auto-merge` activity (effect `merge`) turns on GitHub's native auto-merge, squash at the head it read, for each open lanes App pull request into main that opens with `Closes #` and carries no hold (a `Hold:` line or "do not merge" in its body, `do-not-merge`, the human-gated label, a member's newest comment saying "do not merge"), and turns it off on an armed one that now carries a hold. Auto-merge a person turned off stays off. `pr-merge.yml` runs it in its own job, with its own lanes App token, when the issue lane finishes (a new `workflow_run` trigger the merge job ignores), on a listed human's edit, unlabel or ready, and on the schedule. While `merge.report-only` is true it writes nothing ([README.md, Native auto-merge](https://github.com/melodic-software/claude-code-plugins/blob/main/docs/conventions/pr-pipeline/README.md#native-auto-merge)). diff --git a/.github/scripts/pr-merge/enable-auto-merge.mjs b/.github/scripts/pr-merge/enable-auto-merge.mjs index da581b5fd0..ccc9e43c9f 100755 --- a/.github/scripts/pr-merge/enable-auto-merge.mjs +++ b/.github/scripts/pr-merge/enable-auto-merge.mjs @@ -14,6 +14,11 @@ // tracker's role label, `needs-human` by default), and no body hold, the // gate's own rule (babysit_merge_policy.BODY_MERGE_HOLD_RE): a line opening // with `Hold:`, or "do not merge" anywhere. +// - No member vetoes it, the gate's rule (babysit_merge.human_veto_logins): an +// OWNER, MEMBER or COLLABORATOR whose newest comment, review or inline +// comment with text says "do not merge". A later comment lifts it. +// - No person turned its auto-merge off: the newest auto-merge-disabled event +// by a user account keeps it off until a person turns it on again. // Arming passes the head just read as expectedHeadOid, so a push in between // fails the arm rather than arming a head nobody read. An armed pull request // that fails any rule is disarmed. While merge.report-only is true it reports @@ -42,6 +47,10 @@ const HOLD_LABELS = Object.freeze(["do-not-merge"]); // babysit_merge_policy.BODY_MERGE_HOLD_RE; enable-auto-merge.test.mjs checks the two // agree on every body it tries. export const BODY_HOLD = /\bdo(?:n['’]?t| not)\s+merge\b|(?:^|\r)[^\S\r\n]*hold:/im; +// babysit_merge.HUMAN_MERGE_VETO_RE; enable-auto-merge.test.mjs checks the two agree. +export const HUMAN_VETO = /\bdo(?:n['’]?t| not|-not)[\s-]*merge\b/i; +// babysit_merge_policy.BLOCKER_ASSOCIATIONS. +const MEMBERS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const CLOSES = /^Closes #[1-9][0-9]*\r?$/; // 100 pull requests a page; more than this many open is a listing the run // stops on rather than one it reads only part of. @@ -60,6 +69,21 @@ export const PULLS_QUERY = `query($owner: String!, $name: String!, $after: Strin } } }`; +// One candidate's discussion. The newest 100 of each, and more than that is +// a discussion the run does not arm on rather than one it reads only part of. +export const DETAIL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + comments(last: 100) { totalCount nodes { author { __typename login } body createdAt authorAssociation } } + reviews(last: 100) { totalCount nodes { author { __typename login } body submittedAt authorAssociation } } + reviewThreads(last: 100) { + totalCount + nodes { comments(last: 100) { totalCount nodes { author { __typename login } body createdAt authorAssociation } } } + } + timelineItems(itemTypes: [AUTO_MERGE_DISABLED_EVENT], last: 1) { nodes { ... on AutoMergeDisabledEvent { actor { __typename login } } } } + } + } +}`; export const ARM_MUTATION = `mutation($id: ID!, $head: GitObjectID!) { enablePullRequestAutoMerge(input: { pullRequestId: $id, mergeMethod: SQUASH, expectedHeadOid: $head }) { pullRequest { autoMergeRequest { enabledAt } } @@ -92,8 +116,37 @@ export function holdReason(pull, humanGated) { return null; } +const whole = (connection) => Array.isArray(connection?.nodes) && connection.totalCount <= connection.nodes.length; + +// Why the discussion `detail` (DETAIL_QUERY's pullRequest) holds it, or null. +export function discussionHold(detail) { + const threads = detail?.reviewThreads; + if (![detail?.comments, detail?.reviews, threads].every(whole) || !threads.nodes.every((thread) => whole(thread?.comments))) { + return "its comments could not all be read"; + } + const newest = new Map(); + for (const item of [...detail.comments.nodes, ...detail.reviews.nodes, ...threads.nodes.flatMap((thread) => thread.comments.nodes)]) { + const body = String(item?.body ?? ""); + if (item?.author?.__typename !== "User" || !MEMBERS.has(String(item.authorAssociation).toUpperCase()) || !body.trim()) { + continue; + } + const at = String(item.submittedAt ?? item.createdAt ?? ""); + const login = item.author.login; + if (!newest.has(login) || at >= newest.get(login).at) { + newest.set(login, { at, body }); + } + } + const vetoes = [...newest].filter(([, { body }]) => HUMAN_VETO.test(body)).map(([login]) => login); + if (vetoes.length) { + return `${vetoes.sort().join(", ")} asked not to merge it`; + } + return null; +} + // What to do with one listed pull request: { action: arm | disarm | none, reason }. -export function decide(pull, { bot, humanGated }) { +// `detail` is its discussion; undefined means not read yet, and an arm or a +// keep then waits on it (action `read`). +export function decide(pull, { bot, humanGated }, detail) { if (pull.author?.__typename !== "Bot" || pull.author.login !== bot) { return { action: "none", reason: "not the lanes App's" }; } @@ -109,7 +162,23 @@ export function decide(pull, { bot, humanGated }) { if (held !== null) { return { action: armed ? "disarm" : "none", reason: held }; } - return armed ? { action: "none", reason: "already armed" } : { action: "arm", reason: "no hold" }; + if (detail === undefined) { + return { action: "read", reason: "its discussion is not read yet" }; + } + if (detail === null) { + return { action: "none", reason: "its discussion did not read" }; + } + const vetoed = discussionHold(detail); + if (vetoed !== null) { + return { action: armed ? "disarm" : "none", reason: vetoed }; + } + if (armed) { + return { action: "none", reason: "already armed" }; + } + if (detail.timelineItems?.nodes?.at(-1)?.actor?.__typename === "User") { + return { action: "none", reason: "a person turned its auto-merge off" }; + } + return { action: "arm", reason: "no hold" }; } async function listPulls(github, owner, name) { @@ -150,7 +219,17 @@ export async function main({ env = process.env, github } = {}) { const pulls = await listPulls(github, repo[1], repo[2]); let failed = 0; for (const pull of pulls) { - const { action, reason } = decide(pull, { bot: slug, humanGated }); + let { action, reason } = decide(pull, { bot: slug, humanGated }); + if (action === "read") { + let detail = null; + try { + const variables = { owner: repo[1], name: repo[2], number: pull.number }; + detail = (await github("POST", "/graphql", { query: DETAIL_QUERY, variables }))?.data?.repository?.pullRequest ?? null; + } catch (error) { + console.log(`enable-auto-merge: #${pull.number} discussion did not read: ${escapeCommand(error?.message ?? error)}`); + } + ({ action, reason } = decide(pull, { bot: slug, humanGated }, detail)); + } if (action === "none") { if (reason !== "not the lanes App's") { console.log(`enable-auto-merge: #${pull.number} left: ${reason}`); diff --git a/.github/scripts/pr-merge/enable-auto-merge.test.mjs b/.github/scripts/pr-merge/enable-auto-merge.test.mjs index d101bc048b..2163235b1f 100644 --- a/.github/scripts/pr-merge/enable-auto-merge.test.mjs +++ b/.github/scripts/pr-merge/enable-auto-merge.test.mjs @@ -10,7 +10,7 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; -import { ARM_MUTATION, BODY_HOLD, DISARM_MUTATION, PULLS_QUERY, decide, main } from "./enable-auto-merge.mjs"; +import { ARM_MUTATION, BODY_HOLD, DETAIL_QUERY, DISARM_MUTATION, HUMAN_VETO, PULLS_QUERY, decide, main } from "./enable-auto-merge.mjs"; import { decide as decideOutcome } from "../work-resolve-issue/report-outcome.mjs"; const REPO = "melodic-software/claude-code-plugins"; @@ -36,6 +36,23 @@ const pull = (number, overrides = {}) => ({ autoMergeRequest: null, ...overrides, }); +// One pull request's comments, reviews, inline comments and newest +// auto-merge-disabled event, as DETAIL_QUERY returns them. +const page = (nodes = []) => ({ totalCount: nodes.length, nodes }); +function detail({ comments = [], reviews = [], inline = [], disabledBy = null } = {}) { + return { + comments: page(comments), + reviews: page(reviews), + reviewThreads: page(inline.length ? [{ comments: page(inline) }] : []), + timelineItems: { nodes: disabledBy ? [{ actor: disabledBy }] : [] }, + }; +} +const said = (login, body, at, association = "OWNER", typename = "User") => ({ + author: { __typename: typename, login }, + body, + createdAt: at, + authorAssociation: association, +}); const labels = (...names) => ({ totalCount: names.length, nodes: names.map((name) => ({ name })) }); function world(pulls, { failArm = false } = {}) { @@ -47,6 +64,10 @@ function world(pulls, { failArm = false } = {}) { assert.deepEqual(body.variables, { owner: "melodic-software", name: "claude-code-plugins", after: null }); return { data: { repository: { pullRequests: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: pulls } } } }; } + if (body.query === DETAIL_QUERY) { + const found = pulls.find((candidate) => candidate.number === body.variables.number); + return { data: { repository: { pullRequest: detail(found?.discussion) } } }; + } if (body.query === ARM_MUTATION && failArm) { throw new Error("POST /graphql (GraphQL errors) failed with status 200"); } @@ -54,7 +75,7 @@ function world(pulls, { failArm = false } = {}) { }; const writes = () => calls - .filter(({ body }) => body.query !== PULLS_QUERY) + .filter(({ body }) => body.query !== PULLS_QUERY && body.query !== DETAIL_QUERY) .map(({ body }) => ({ mutation: body.query === ARM_MUTATION ? "arm" : body.query === DISARM_MUTATION ? "disarm" : "?", ...body.variables })); return { github, writes }; } @@ -210,10 +231,76 @@ test("report-outcome's own pull request body arms, and its hold and Refs bodies const verdict = (overrides) => { const opened = plan(overrides); assert.equal(opened.action, "open-pr"); - return decide(pull(1, { body: opened.body }), { bot: "melodic-automation-lanes", humanGated: "needs-human" }); + return decide(pull(1, { body: opened.body }), { bot: "melodic-automation-lanes", humanGated: "needs-human" }, detail()); }; assert.deepEqual(verdict({}), { action: "arm", reason: "no hold" }); assert.equal(verdict({ humanMerge: true }).action, "none"); assert.equal(verdict({ changedFiles: [".github/workflows/x.yml"] }).action, "none"); assert.equal(verdict({ outcome: { exit: "done", reason: "", title: "t", summary: "s", link: "refs" } }).action, "none"); }); + +// The gate's human veto (babysit_merge.human_veto_logins): each member's +// newest comment, review or inline comment with text decides, so a later +// comment lifts the hold; a non-member's, a bot's or an empty one counts for +// nothing. Native auto-merge skips that gate, so the arm rule applies it. +test("a member's newest comment saying not to merge holds, and a later one lifts it", async () => { + const veto = said("kyle-sexton", "Please don't merge this yet.", "2026-10-23T10:00:00Z"); + const { code, writes } = await run([ + pull(1, { discussion: { comments: [veto] } }), + pull(2, { discussion: { comments: [veto, said("kyle-sexton", "Go ahead.", "2026-10-23T11:00:00Z")] } }), + pull(3, { discussion: { comments: [said("someone", "do not merge", "2026-10-23T10:00:00Z", "CONTRIBUTOR")] } }), + pull(4, { discussion: { comments: [said("codex", "do not merge", "2026-10-23T10:00:00Z", "NONE", "Bot")] } }), + pull(5, { discussion: { reviews: [{ ...veto, createdAt: undefined, submittedAt: veto.createdAt }] } }), + pull(6, { discussion: { inline: [said("kyle-sexton", "do-not merge until the canary", "2026-10-23T10:00:00Z", "MEMBER")] } }), + pull(7, { discussion: { comments: [veto, said("kyle-sexton", "", "2026-10-23T11:00:00Z")] } }), + ]); + assert.equal(code, 0); + assert.deepEqual( + writes.map(({ id }) => id), + ["PR_2", "PR_3", "PR_4"], + ); +}); + +test("an armed pull request a member now vetoes is disarmed", async () => { + const { writes } = await run([ + pull(1, { autoMergeRequest: {}, discussion: { comments: [said("kyle-sexton", "do not merge", "2026-10-23T10:00:00Z")] } }), + ]); + assert.deepEqual(writes, [{ mutation: "disarm", id: "PR_1" }]); +}); + +test("a pull request whose discussion cannot all be read is not armed", async () => { + const cut = { totalCount: 101, nodes: [] }; + const held = (pr) => decide(pull(1), { bot: LANES.login, humanGated: "needs-human" }, { ...detail(), ...pr }); + assert.equal(held({ comments: cut }).action, "none"); + assert.equal(held({ reviews: cut }).action, "none"); + assert.equal(held({ reviewThreads: cut }).action, "none"); + assert.equal(held({ reviewThreads: page([{ comments: cut }]) }).action, "none"); + // A read that failed leaves it as it is, armed or not, until the next run. + assert.equal(decide(pull(1), { bot: LANES.login, humanGated: "needs-human" }, null).action, "none"); + assert.equal(decide(pull(1, { autoMergeRequest: {} }), { bot: LANES.login, humanGated: "needs-human" }, null).action, "none"); +}); + +test("auto-merge a person turned off stays off; one the lanes App turned off is armed again", async () => { + const { writes } = await run([ + pull(1, { discussion: { disabledBy: { __typename: "User", login: "kyle-sexton" } } }), + pull(2, { discussion: { disabledBy: { __typename: "Bot", login: "melodic-automation-lanes" } } }), + ]); + assert.deepEqual(writes, [{ mutation: "arm", id: "PR_2", head: HEAD }]); +}); + +test("the comment veto agrees with the gate's HUMAN_MERGE_VETO_RE", () => { + const bodies = ["do not merge", "Don't merge", "don’t merge", "dont-merge", "do-not merge", "Do NOT\nmerge", "donut merge", "merge it", "doesn't merge", ""]; + const scripts = fileURLToPath(new URL("../../../plugins/source-control/skills/babysit-prs/scripts", import.meta.url)); + const python = spawnSync( + "python3", + ["-c", "import json,sys\nfrom babysit_merge import HUMAN_MERGE_VETO_RE as R\nprint(json.dumps([bool(R.search(b)) for b in json.load(sys.stdin)]))"], + { cwd: scripts, input: JSON.stringify(bodies), encoding: "utf8" }, + ); + assert.equal(python.status, 0, python.stderr); + const expected = JSON.parse(python.stdout); + assert.deepEqual( + bodies.map((body) => HUMAN_VETO.test(body)), + expected, + ); + assert.ok(expected.includes(true) && expected.includes(false)); +}); diff --git a/.github/scripts/pr-merge/workflow.test.mjs b/.github/scripts/pr-merge/workflow.test.mjs index 3efa228255..8a77b75a53 100644 --- a/.github/scripts/pr-merge/workflow.test.mjs +++ b/.github/scripts/pr-merge/workflow.test.mjs @@ -156,7 +156,7 @@ test("the issue lane finishing wakes only the enable-auto-merge job", () => { assert.match(job("merge"), /\n {6}&& github\.event_name != 'workflow_run'\n/); const arm = job("enable-auto-merge"); const armIf = /\n {4}if: >-\n([\s\S]*?)\n {4}[a-z-]+:/.exec(arm)[1]; - assert.doesNotMatch(armIf, /workflow_run'\n/); + assert.match(armIf, /github\.event_name != 'workflow_run' \|\| github\.event\.workflow_run\.conclusion != 'skipped'/); // A push to main is a merge landing, never a new lane pull request. assert.match(armIf, /github\.event_name != 'push'/); assert.match(arm, /\n {4}concurrency:\n {6}group: pr-merge-enable-auto-merge\n {6}queue: max\n/); @@ -167,6 +167,10 @@ test("enable-auto-merge asks the broker for its own activity's merge token", () const steps = [...arm.matchAll(/\n {10}lane: (.*)\n {10}activity: (.*)\n/g)].map((m) => `${m[1]}/${m[2]}`); assert.deepEqual(steps, ["pr-merge/enable-auto-merge", "pr-merge/enable-auto-merge"]); assert.match(arm, /node \.base\/\.github\/scripts\/pr-merge\/enable-auto-merge\.mjs/); + // A missing merge.report-only reaches the script as `null`, which it reads + // as report-only; the job must not turn it into `false`. + assert.match(arm, /REPORT_ONLY=\$\(yq '\.merge\."report-only"' "\$config"\)\n/); + assert.doesNotMatch(arm, /REPORT_ONLY=false/); const block = /\n {4}permissions:\n((?: {6}[a-z-]+: [a-z]+.*\n)+)/.exec(arm)[1]; assert.deepEqual( [...block.matchAll(/ {6}([a-z-]+): ([a-z]+)/g)].map((m) => `${m[1]}: ${m[2]}`), diff --git a/.github/workflows/pr-merge.yml b/.github/workflows/pr-merge.yml index b1d91ad754..e47d085f1c 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -1315,7 +1315,6 @@ jobs: set -euo pipefail config=.base/docs/conventions/pr-pipeline.yaml REPORT_ONLY=$(yq '.merge."report-only"' "$config") - [ "$REPORT_ONLY" = null ] && REPORT_ONLY=false LANES_BOT_LOGIN=$(yq '.merge."agent-identity" // ""' "$config") export REPORT_ONLY LANES_BOT_LOGIN node .base/.github/scripts/pr-merge/enable-auto-merge.mjs diff --git a/docs/conventions/pr-pipeline.yaml b/docs/conventions/pr-pipeline.yaml index 573e8ea752..125fb8ea88 100644 --- a/docs/conventions/pr-pipeline.yaml +++ b/docs/conventions/pr-pipeline.yaml @@ -364,14 +364,15 @@ activities: # Turns on GitHub's native auto-merge (squash, at the head it read) for # each open lanes App pull request into main that opens with `Closes #` # and carries no hold (a `Hold:` line, do-not-merge, the human-gated - # label), and turns it off on an armed one that now carries a hold. No + # label, a member's "do not merge" comment), and turns it off on an armed + # one that now carries a hold; auto-merge a person turned off stays off. No # model. The enable-auto-merge job of .github/workflows/pr-merge.yml runs # it when the issue lane finishes, on a listed human's PR edit, and on the # schedule. script: .github/scripts/pr-merge/enable-auto-merge.mjs effect: merge gating: advisory - # Reads PR bodies and labels for holds. + # Reads PR bodies, labels and comments for holds. reads-untrusted: true inputs: [pr, head-sha] label-unblocked: diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index 931c421ea4..19443b8ff8 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -1019,8 +1019,10 @@ own lanes App token (`.github/scripts/pr-merge/enable-auto-merge.mjs`, no model) native auto-merge (`enablePullRequestAutoMerge`, squash, `expectedHeadOid` the head it read) for each open lanes App pull request into main from a branch of this repository that is not a draft, opens with `Closes #`, and carries no hold: no `Hold:` line or "do not merge" in its body -(the gate's own rule) and no `do-not-merge` or human-gated label. It turns auto-merge off on an -armed one that now fails any of those. GitHub then merges the pull request through the merge queue +(the gate's own rule), no `do-not-merge` or human-gated label, and no member (owner, member or +collaborator) whose newest comment, review or inline comment with text says "do not merge" (the +gate's veto rule; a later comment lifts it). It turns auto-merge off on an armed one that now fails +any of those, and does not turn it back on once a person has turned it off. GitHub then merges the pull request through the merge queue once `ci-status` passes. The issue lane finishing (`workflow_run`) wakes it, so a pull request is armed right after report-outcome opens it; a listed human's edit, unlabel or ready wakes it too, so adding a `Hold:` line disarms at once; and the 15-minute schedule arms or disarms what the events From 2f6b030f0fd276b8aa5c2968c1e6c95b9a1c0788 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:22:51 -0400 Subject: [PATCH 06/15] fix(pr-pipeline): check hold paths on the armed head's files report-outcome's Hold: line covers only the files at opening, and native auto-merge stays armed across later pushes. The arm rule now reads the files the head changes now (renamed-from paths included) against the merge lane's denied paths from the resolved config, using report-outcome's readHoldPaths; a match leaves it unarmed or disarms it, and unreadable hold paths or more than 300 changed files arm nothing. Co-Authored-By: Claude Opus 5.5 --- ...eat-enable-auto-merge-activity-4e7a2c91.md | 2 +- .../scripts/pr-merge/enable-auto-merge.mjs | 51 +++++++++++++++++-- .../pr-merge/enable-auto-merge.test.mjs | 41 +++++++++++++-- .../fixtures/enable-auto-merge-resolved.json | 7 +++ .github/scripts/pr-merge/workflow.test.mjs | 3 ++ .github/workflows/pr-merge.yml | 1 + docs/conventions/pr-pipeline.yaml | 5 +- docs/conventions/pr-pipeline/README.md | 3 +- 8 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 .github/scripts/pr-merge/fixtures/enable-auto-merge-resolved.json diff --git a/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md b/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md index 9806e65e8d..3eb4810388 100644 --- a/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md +++ b/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md @@ -4,4 +4,4 @@ bump: minor ### Added -- The pr-merge lane's `enable-auto-merge` activity (effect `merge`) turns on GitHub's native auto-merge, squash at the head it read, for each open lanes App pull request into main that opens with `Closes #` and carries no hold (a `Hold:` line or "do not merge" in its body, `do-not-merge`, the human-gated label, a member's newest comment saying "do not merge"), and turns it off on an armed one that now carries a hold. Auto-merge a person turned off stays off. `pr-merge.yml` runs it in its own job, with its own lanes App token, when the issue lane finishes (a new `workflow_run` trigger the merge job ignores), on a listed human's edit, unlabel or ready, and on the schedule. While `merge.report-only` is true it writes nothing ([README.md, Native auto-merge](https://github.com/melodic-software/claude-code-plugins/blob/main/docs/conventions/pr-pipeline/README.md#native-auto-merge)). +- The pr-merge lane's `enable-auto-merge` activity (effect `merge`) turns on GitHub's native auto-merge, squash at the head it read, for each open lanes App pull request into main that opens with `Closes #` and carries no hold (a `Hold:` line or "do not merge" in its body, `do-not-merge`, the human-gated label, a member's newest comment saying "do not merge", a head that now changes a denied path), and turns it off on an armed one that now carries a hold. Auto-merge a person turned off stays off. `pr-merge.yml` runs it in its own job, with its own lanes App token, when the issue lane finishes (a new `workflow_run` trigger the merge job ignores), on a listed human's edit, unlabel or ready, and on the schedule. While `merge.report-only` is true it writes nothing ([README.md, Native auto-merge](https://github.com/melodic-software/claude-code-plugins/blob/main/docs/conventions/pr-pipeline/README.md#native-auto-merge)). diff --git a/.github/scripts/pr-merge/enable-auto-merge.mjs b/.github/scripts/pr-merge/enable-auto-merge.mjs index ccc9e43c9f..733a63ceaf 100755 --- a/.github/scripts/pr-merge/enable-auto-merge.mjs +++ b/.github/scripts/pr-merge/enable-auto-merge.mjs @@ -17,6 +17,10 @@ // - No member vetoes it, the gate's rule (babysit_merge.human_veto_logins): an // OWNER, MEMBER or COLLABORATOR whose newest comment, review or inline // comment with text says "do not merge". A later comment lifts it. +// - Its head changes no hold path: the merge lane's denied paths +// (report-outcome.readHoldPaths), read against the files, renamed-from paths +// included, that the head changes now, since a push after report-outcome +// wrote its Hold: line can add one. // - No person turned its auto-merge off: the newest auto-merge-disabled event // by a user account keeps it off until a person turns it on again. // Arming passes the head just read as expectedHeadOid, so a push in between @@ -32,12 +36,15 @@ // LANES_BOT_LOGIN merge.agent-identity, an App bot login // TRACKER_CONFIG the base .work-item-tracker.json, for the human-gated // label (default .base/.work-item-tracker.json) +// RESOLVED_CONFIG the resolve-config output, for the hold paths // GITHUB_API_URL +import { posix } from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; import { createGitHub } from "../../actions/check-trusted-trigger/check-trusted-trigger.mjs"; import { humanGatedLabelFrom } from "../pr-unblock/address-feedback.mjs"; +import { readHoldPaths } from "../work-resolve-issue/report-outcome.mjs"; const REPO = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/; const SHA = /^[0-9a-f]{40}$/; @@ -55,6 +62,9 @@ const CLOSES = /^Closes #[1-9][0-9]*\r?$/; // 100 pull requests a page; more than this many open is a listing the run // stops on rather than one it reads only part of. const MAX_PAGES = 10; +// Changed files read per pull request, 100 a page; more is a head the run +// does not arm on. +const MAX_FILE_PAGES = 3; export const PULLS_QUERY = `query($owner: String!, $name: String!, $after: String) { repository(owner: $owner, name: $name) { @@ -143,10 +153,39 @@ export function discussionHold(detail) { return null; } +// Why the head's changed `files` hold it, or null. +export function pathHold(files, holdPaths) { + if (!Array.isArray(holdPaths)) { + return "the hold paths could not be read"; + } + if (!Array.isArray(files)) { + return "its changed files could not all be read"; + } + const held = files.find((path) => holdPaths.some((glob) => posix.matchesGlob(path, glob))); + return held === undefined ? null : `it changes ${held}, a hold path`; +} + +async function changedFiles(github, slug, number) { + const paths = []; + for (let page = 1; page <= MAX_FILE_PAGES; page += 1) { + const rows = await github("GET", `/repos/${slug}/pulls/${number}/files?per_page=100&page=${page}`); + if (!Array.isArray(rows)) { + return null; + } + for (const row of rows) { + paths.push(String(row?.filename), ...(row?.previous_filename ? [String(row.previous_filename)] : [])); + } + if (rows.length < 100) { + return paths; + } + } + return null; +} + // What to do with one listed pull request: { action: arm | disarm | none, reason }. // `detail` is its discussion; undefined means not read yet, and an arm or a // keep then waits on it (action `read`). -export function decide(pull, { bot, humanGated }, detail) { +export function decide(pull, { bot, humanGated, holdPaths = null }, detail) { if (pull.author?.__typename !== "Bot" || pull.author.login !== bot) { return { action: "none", reason: "not the lanes App's" }; } @@ -168,7 +207,7 @@ export function decide(pull, { bot, humanGated }, detail) { if (detail === null) { return { action: "none", reason: "its discussion did not read" }; } - const vetoed = discussionHold(detail); + const vetoed = pathHold(detail.files, holdPaths) ?? discussionHold(detail); if (vetoed !== null) { return { action: armed ? "disarm" : "none", reason: vetoed }; } @@ -215,20 +254,24 @@ export async function main({ env = process.env, github } = {}) { } const write = env.REPORT_ONLY === "false"; const humanGated = humanGatedLabelFrom(env.TRACKER_CONFIG || ".base/.work-item-tracker.json"); + const holdPaths = readHoldPaths(env.RESOLVED_CONFIG ?? ""); github ??= createGitHub({ token: env.GH_TOKEN, apiUrl: env.GITHUB_API_URL || undefined }); const pulls = await listPulls(github, repo[1], repo[2]); let failed = 0; for (const pull of pulls) { - let { action, reason } = decide(pull, { bot: slug, humanGated }); + let { action, reason } = decide(pull, { bot: slug, humanGated, holdPaths }); if (action === "read") { let detail = null; try { const variables = { owner: repo[1], name: repo[2], number: pull.number }; detail = (await github("POST", "/graphql", { query: DETAIL_QUERY, variables }))?.data?.repository?.pullRequest ?? null; + if (detail !== null) { + detail = { ...detail, files: await changedFiles(github, `${repo[1]}/${repo[2]}`, pull.number) }; + } } catch (error) { console.log(`enable-auto-merge: #${pull.number} discussion did not read: ${escapeCommand(error?.message ?? error)}`); } - ({ action, reason } = decide(pull, { bot: slug, humanGated }, detail)); + ({ action, reason } = decide(pull, { bot: slug, humanGated, holdPaths }, detail)); } if (action === "none") { if (reason !== "not the lanes App's") { diff --git a/.github/scripts/pr-merge/enable-auto-merge.test.mjs b/.github/scripts/pr-merge/enable-auto-merge.test.mjs index 2163235b1f..d90af9679b 100644 --- a/.github/scripts/pr-merge/enable-auto-merge.test.mjs +++ b/.github/scripts/pr-merge/enable-auto-merge.test.mjs @@ -22,6 +22,7 @@ const ENV = { REPORT_ONLY: "false", LANES_BOT_LOGIN: "melodic-automation-lanes[bot]", TRACKER_CONFIG: "/nonexistent/.work-item-tracker.json", + RESOLVED_CONFIG: fileURLToPath(new URL("./fixtures/enable-auto-merge-resolved.json", import.meta.url)), }; const pull = (number, overrides = {}) => ({ @@ -59,6 +60,11 @@ function world(pulls, { failArm = false } = {}) { const calls = []; const github = async (method, path, body) => { calls.push({ method, path, body }); + const listing = /^\/repos\/melodic-software\/claude-code-plugins\/pulls\/([0-9]+)\/files\?per_page=100&page=([0-9]+)$/.exec(path); + if (method === "GET" && listing) { + const files = pulls.find((candidate) => candidate.number === Number(listing[1]))?.files ?? [{ filename: "plugins/work-items/README.md" }]; + return files.slice((Number(listing[2]) - 1) * 100, Number(listing[2]) * 100); + } assert.equal(`${method} ${path}`, "POST /graphql"); if (body.query === PULLS_QUERY) { assert.deepEqual(body.variables, { owner: "melodic-software", name: "claude-code-plugins", after: null }); @@ -75,7 +81,7 @@ function world(pulls, { failArm = false } = {}) { }; const writes = () => calls - .filter(({ body }) => body.query !== PULLS_QUERY && body.query !== DETAIL_QUERY) + .filter(({ method, body }) => method === "POST" && body.query !== PULLS_QUERY && body.query !== DETAIL_QUERY) .map(({ body }) => ({ mutation: body.query === ARM_MUTATION ? "arm" : body.query === DISARM_MUTATION ? "disarm" : "?", ...body.variables })); return { github, writes }; } @@ -231,7 +237,8 @@ test("report-outcome's own pull request body arms, and its hold and Refs bodies const verdict = (overrides) => { const opened = plan(overrides); assert.equal(opened.action, "open-pr"); - return decide(pull(1, { body: opened.body }), { bot: "melodic-automation-lanes", humanGated: "needs-human" }, detail()); + // Files outside the hold paths, so only the body decides. + return decide(pull(1, { body: opened.body }), { bot: "melodic-automation-lanes", humanGated: "needs-human", holdPaths: [".github/**"] }, { ...detail(), files: [] }); }; assert.deepEqual(verdict({}), { action: "arm", reason: "no hold" }); assert.equal(verdict({ humanMerge: true }).action, "none"); @@ -270,14 +277,16 @@ test("an armed pull request a member now vetoes is disarmed", async () => { test("a pull request whose discussion cannot all be read is not armed", async () => { const cut = { totalCount: 101, nodes: [] }; - const held = (pr) => decide(pull(1), { bot: LANES.login, humanGated: "needs-human" }, { ...detail(), ...pr }); + const options = { bot: LANES.login, humanGated: "needs-human", holdPaths: [] }; + assert.equal(decide(pull(1), options, { ...detail(), files: [] }).action, "arm"); + const held = (pr) => decide(pull(1), options, { ...detail(), files: [], ...pr }); assert.equal(held({ comments: cut }).action, "none"); assert.equal(held({ reviews: cut }).action, "none"); assert.equal(held({ reviewThreads: cut }).action, "none"); assert.equal(held({ reviewThreads: page([{ comments: cut }]) }).action, "none"); // A read that failed leaves it as it is, armed or not, until the next run. - assert.equal(decide(pull(1), { bot: LANES.login, humanGated: "needs-human" }, null).action, "none"); - assert.equal(decide(pull(1, { autoMergeRequest: {} }), { bot: LANES.login, humanGated: "needs-human" }, null).action, "none"); + assert.equal(decide(pull(1), options, null).action, "none"); + assert.equal(decide(pull(1, { autoMergeRequest: {} }), options, null).action, "none"); }); test("auto-merge a person turned off stays off; one the lanes App turned off is armed again", async () => { @@ -304,3 +313,25 @@ test("the comment veto agrees with the gate's HUMAN_MERGE_VETO_RE", () => { ); assert.ok(expected.includes(true) && expected.includes(false)); }); + +// The merge lane's denied paths (report-outcome.readHoldPaths), on the head's +// files now: report-outcome's Hold: line covers only the files at opening, and +// a later push can add a held path to a pull request that is already armed. +test("a pull request whose head now changes a hold path is not armed, or is disarmed", async () => { + const { writes } = await run([ + pull(1, { files: [{ filename: ".github/workflows/x.yml" }] }), + pull(2, { autoMergeRequest: {}, files: [{ filename: "plugins/a/README.md" }, { filename: "docs/a.md", previous_filename: ".github/a.md" }] }), + pull(3, { files: Array.from({ length: 301 }, (_, index) => ({ filename: `docs/${index}.md` })) }), + pull(4, { files: [{ filename: "docs/held.md" }] }), + pull(5), + ]); + assert.deepEqual(writes, [ + { mutation: "disarm", id: "PR_2" }, + { mutation: "arm", id: "PR_5", head: HEAD }, + ]); +}); + +test("hold paths that cannot be read arm nothing", async () => { + const { writes } = await run([pull(1)], { RESOLVED_CONFIG: "/nonexistent/resolved-config.json" }); + assert.deepEqual(writes, []); +}); diff --git a/.github/scripts/pr-merge/fixtures/enable-auto-merge-resolved.json b/.github/scripts/pr-merge/fixtures/enable-auto-merge-resolved.json new file mode 100644 index 0000000000..47a76fc4cd --- /dev/null +++ b/.github/scripts/pr-merge/fixtures/enable-auto-merge-resolved.json @@ -0,0 +1,7 @@ +{ + "merge": { + "diff-check": { + "denied-paths": [".github/**", "docs/held.md"] + } + } +} diff --git a/.github/scripts/pr-merge/workflow.test.mjs b/.github/scripts/pr-merge/workflow.test.mjs index 8a77b75a53..40cd2edfd8 100644 --- a/.github/scripts/pr-merge/workflow.test.mjs +++ b/.github/scripts/pr-merge/workflow.test.mjs @@ -171,6 +171,9 @@ test("enable-auto-merge asks the broker for its own activity's merge token", () // as report-only; the job must not turn it into `false`. assert.match(arm, /REPORT_ONLY=\$\(yq '\.merge\."report-only"' "\$config"\)\n/); assert.doesNotMatch(arm, /REPORT_ONLY=false/); + // The hold paths come from the config the job resolved. + assert.match(arm, /output-path: \$\{\{ runner\.temp \}\}\/resolved-config\.json\n/); + assert.match(arm, /\n {10}RESOLVED_CONFIG: \$\{\{ runner\.temp \}\}\/resolved-config\.json\n/); const block = /\n {4}permissions:\n((?: {6}[a-z-]+: [a-z]+.*\n)+)/.exec(arm)[1]; assert.deepEqual( [...block.matchAll(/ {6}([a-z-]+): ([a-z]+)/g)].map((m) => `${m[1]}: ${m[2]}`), diff --git a/.github/workflows/pr-merge.yml b/.github/workflows/pr-merge.yml index e47d085f1c..87f770455b 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -1311,6 +1311,7 @@ jobs: env: GH_TOKEN: ${{ steps.lane-token.outputs.token }} KILL_SWITCH: ${{ steps.kill.outputs.proceed == 'true' && 'proceed' || '' }} + RESOLVED_CONFIG: ${{ runner.temp }}/resolved-config.json run: | set -euo pipefail config=.base/docs/conventions/pr-pipeline.yaml diff --git a/docs/conventions/pr-pipeline.yaml b/docs/conventions/pr-pipeline.yaml index 125fb8ea88..e929d02e3d 100644 --- a/docs/conventions/pr-pipeline.yaml +++ b/docs/conventions/pr-pipeline.yaml @@ -364,8 +364,9 @@ activities: # Turns on GitHub's native auto-merge (squash, at the head it read) for # each open lanes App pull request into main that opens with `Closes #` # and carries no hold (a `Hold:` line, do-not-merge, the human-gated - # label, a member's "do not merge" comment), and turns it off on an armed - # one that now carries a hold; auto-merge a person turned off stays off. No + # label, a member's "do not merge" comment, a head that changes a denied + # path), and turns it off on an armed one that now carries a hold; + # auto-merge a person turned off stays off. No # model. The enable-auto-merge job of .github/workflows/pr-merge.yml runs # it when the issue lane finishes, on a listed human's PR edit, and on the # schedule. diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index f44a2b5041..ed923fb6ad 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -1021,7 +1021,8 @@ each open lanes App pull request into main from a branch of this repository that opens with `Closes #`, and carries no hold: no `Hold:` line or "do not merge" in its body (the gate's own rule), no `do-not-merge` or human-gated label, and no member (owner, member or collaborator) whose newest comment, review or inline comment with text says "do not merge" (the -gate's veto rule; a later comment lifts it). It turns auto-merge off on an armed one that now fails +gate's veto rule; a later comment lifts it), and whose head changes no path in the lane's +`merge.diff-check.denied-paths`, read on the files the head changes at each run. It turns auto-merge off on an armed one that now fails any of those, and does not turn it back on once a person has turned it off. GitHub then merges the pull request through the merge queue once `ci-status` passes. The issue lane finishing (`workflow_run`) wakes it, so a pull request is armed right after report-outcome opens it; a listed human's edit, unlabel or ready wakes it too, so From 48340a6f7400c285639834ddd6eb95831540d0e8 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:55:20 -0400 Subject: [PATCH 07/15] feat(pr-pipeline): fail ci-status on a lanes App PR's body hold or member veto Native auto-merge merges a lanes App pull request without pr-merge's gate, so ci-status now fails on the two text holds that gate honours, on each pull request event and in the merge queue: a body hold (a Hold: line or "do not merge") and a member's newest comment saying not to merge. The rules move to .github/scripts/pr-merge/merge-holds.mjs, which enable-auto-merge.mjs arms by and ci-status runs from the base commit. Co-Authored-By: Claude Opus 5.5 --- ...eat-enable-auto-merge-activity-4e7a2c91.md | 2 +- .../scripts/pr-merge/enable-auto-merge.mjs | 47 ++-- .github/scripts/pr-merge/merge-holds.mjs | 96 ++++++++ .github/workflows/pr-require-checks.yml | 73 +++++- AGENTS.md | 3 +- docs/conventions/pr-pipeline/README.md | 8 + scripts/ci-lanes-pr-hold.test.sh | 214 ++++++++++++++++++ 7 files changed, 408 insertions(+), 35 deletions(-) create mode 100755 .github/scripts/pr-merge/merge-holds.mjs create mode 100755 scripts/ci-lanes-pr-hold.test.sh diff --git a/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md b/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md index 3eb4810388..853a28735e 100644 --- a/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md +++ b/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md @@ -4,4 +4,4 @@ bump: minor ### Added -- The pr-merge lane's `enable-auto-merge` activity (effect `merge`) turns on GitHub's native auto-merge, squash at the head it read, for each open lanes App pull request into main that opens with `Closes #` and carries no hold (a `Hold:` line or "do not merge" in its body, `do-not-merge`, the human-gated label, a member's newest comment saying "do not merge", a head that now changes a denied path), and turns it off on an armed one that now carries a hold. Auto-merge a person turned off stays off. `pr-merge.yml` runs it in its own job, with its own lanes App token, when the issue lane finishes (a new `workflow_run` trigger the merge job ignores), on a listed human's edit, unlabel or ready, and on the schedule. While `merge.report-only` is true it writes nothing ([README.md, Native auto-merge](https://github.com/melodic-software/claude-code-plugins/blob/main/docs/conventions/pr-pipeline/README.md#native-auto-merge)). +- The pr-merge lane's `enable-auto-merge` activity (effect `merge`) turns on GitHub's native auto-merge, squash at the head it read, for each open lanes App pull request into main that opens with `Closes #` and carries no hold (a `Hold:` line or "do not merge" in its body, `do-not-merge`, the human-gated label, a member's newest comment saying "do not merge", a head that now changes a denied path), and turns it off on an armed one that now carries a hold. Auto-merge a person turned off stays off. `pr-merge.yml` runs it in its own job, with its own lanes App token, when the issue lane finishes (a new `workflow_run` trigger the merge job ignores), on a listed human's edit, unlabel or ready, and on the schedule. While `merge.report-only` is true it writes nothing ([README.md, Native auto-merge](https://github.com/melodic-software/claude-code-plugins/blob/main/docs/conventions/pr-pipeline/README.md#native-auto-merge)). `ci-status` fails a lanes App pull request on a body hold or a member's veto comment, on each pull request event and in the merge queue, so a hold added after arming stops the merge. diff --git a/.github/scripts/pr-merge/enable-auto-merge.mjs b/.github/scripts/pr-merge/enable-auto-merge.mjs index 733a63ceaf..e5bccee984 100755 --- a/.github/scripts/pr-merge/enable-auto-merge.mjs +++ b/.github/scripts/pr-merge/enable-auto-merge.mjs @@ -11,12 +11,11 @@ // - Its body opens with `Closes #`: a `Refs:` pull request is merged // by a person (report-outcome.mjs). // - It carries no hold: no `do-not-merge` and no human-gated label (the -// tracker's role label, `needs-human` by default), and no body hold, the -// gate's own rule (babysit_merge_policy.BODY_MERGE_HOLD_RE): a line opening -// with `Hold:`, or "do not merge" anywhere. -// - No member vetoes it, the gate's rule (babysit_merge.human_veto_logins): an -// OWNER, MEMBER or COLLABORATOR whose newest comment, review or inline -// comment with text says "do not merge". A later comment lifts it. +// tracker's role label, `needs-human` by default), and no body hold +// (merge-holds.mjs). +// - No member vetoes it (merge-holds.mjs). ci-status fails on both holds too, +// so one added after arming stops the merge queue rather than waiting for +// this job's next run. // - Its head changes no hold path: the merge lane's denied paths // (report-outcome.readHoldPaths), read against the files, renamed-from paths // included, that the head changes now, since a push after report-outcome @@ -45,19 +44,15 @@ import { pathToFileURL } from "node:url"; import { createGitHub } from "../../actions/check-trusted-trigger/check-trusted-trigger.mjs"; import { humanGatedLabelFrom } from "../pr-unblock/address-feedback.mjs"; import { readHoldPaths } from "../work-resolve-issue/report-outcome.mjs"; +import { BODY_HOLD, vetoers } from "./merge-holds.mjs"; + +export { BODY_HOLD, HUMAN_VETO } from "./merge-holds.mjs"; const REPO = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/; const SHA = /^[0-9a-f]{40}$/; const BOT_LOGIN = /^([a-z0-9][a-z0-9-]*)\[bot\]$/; // babysit_merge_policy.MERGE_HOLD_LABELS, plus the human-gated label. const HOLD_LABELS = Object.freeze(["do-not-merge"]); -// babysit_merge_policy.BODY_MERGE_HOLD_RE; enable-auto-merge.test.mjs checks the two -// agree on every body it tries. -export const BODY_HOLD = /\bdo(?:n['’]?t| not)\s+merge\b|(?:^|\r)[^\S\r\n]*hold:/im; -// babysit_merge.HUMAN_MERGE_VETO_RE; enable-auto-merge.test.mjs checks the two agree. -export const HUMAN_VETO = /\bdo(?:n['’]?t| not|-not)[\s-]*merge\b/i; -// babysit_merge_policy.BLOCKER_ASSOCIATIONS. -const MEMBERS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const CLOSES = /^Closes #[1-9][0-9]*\r?$/; // 100 pull requests a page; more than this many open is a listing the run // stops on rather than one it reads only part of. @@ -134,23 +129,15 @@ export function discussionHold(detail) { if (![detail?.comments, detail?.reviews, threads].every(whole) || !threads.nodes.every((thread) => whole(thread?.comments))) { return "its comments could not all be read"; } - const newest = new Map(); - for (const item of [...detail.comments.nodes, ...detail.reviews.nodes, ...threads.nodes.flatMap((thread) => thread.comments.nodes)]) { - const body = String(item?.body ?? ""); - if (item?.author?.__typename !== "User" || !MEMBERS.has(String(item.authorAssociation).toUpperCase()) || !body.trim()) { - continue; - } - const at = String(item.submittedAt ?? item.createdAt ?? ""); - const login = item.author.login; - if (!newest.has(login) || at >= newest.get(login).at) { - newest.set(login, { at, body }); - } - } - const vetoes = [...newest].filter(([, { body }]) => HUMAN_VETO.test(body)).map(([login]) => login); - if (vetoes.length) { - return `${vetoes.sort().join(", ")} asked not to merge it`; - } - return null; + const items = [...detail.comments.nodes, ...detail.reviews.nodes, ...threads.nodes.flatMap((thread) => thread.comments.nodes)].map((item) => ({ + login: item?.author?.login, + isUser: item?.author?.__typename === "User", + association: item?.authorAssociation, + at: String(item?.submittedAt ?? item?.createdAt ?? ""), + body: String(item?.body ?? ""), + })); + const vetoes = vetoers(items); + return vetoes.length ? `${vetoes.join(", ")} asked not to merge it` : null; } // Why the head's changed `files` hold it, or null. diff --git a/.github/scripts/pr-merge/merge-holds.mjs b/.github/scripts/pr-merge/merge-holds.mjs new file mode 100755 index 0000000000..a67f77898c --- /dev/null +++ b/.github/scripts/pr-merge/merge-holds.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +// merge-holds.mjs: the merge holds a pull request's own text carries, by the +// rules pr-merge's gate reads them. enable-auto-merge.mjs arms nothing they +// hold, and ci-status runs this file alone, fetched from the base commit, on +// every lanes App pull request, so it imports no other file of the repository. +// +// - A body hold (babysit_merge_policy.BODY_MERGE_HOLD_RE): a line opening with +// `Hold:`, or "do not merge" anywhere. Removing that text lifts it. +// - A member veto (babysit_merge.human_veto_logins): each OWNER, MEMBER or +// COLLABORATOR person's newest comment, review or inline comment with text +// decides, and holds when it says "do not merge" +// (babysit_merge.HUMAN_MERGE_VETO_RE). A later one by the same person +// lifts it; an item with no text lifts nothing. +// +// Command line: merge-holds.mjs . The +// discussion file holds one REST issue comment, review or review comment per +// line. Each hold prints as an ::error:: and exits 1; no hold exits 0. +import { readFileSync } from "node:fs"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +// enable-auto-merge.test.mjs checks both agree with the gate's Python on every +// text it tries. +export const BODY_HOLD = /\bdo(?:n['’]?t| not)\s+merge\b|(?:^|\r)[^\S\r\n]*hold:/im; +export const HUMAN_VETO = /\bdo(?:n['’]?t| not|-not)[\s-]*merge\b/i; +// babysit_merge_policy.BLOCKER_ASSOCIATIONS. +const MEMBERS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); + +// The members whose newest item with text vetoes the merge, sorted. Each item +// is { login, isUser, association, at, body }, `at` an ISO 8601 UTC time. +export function vetoers(items) { + const newest = new Map(); + for (const { login, isUser, association, at, body } of items) { + if (!isUser || !MEMBERS.has(String(association).toUpperCase()) || !String(body ?? "").trim()) { + continue; + } + if (!newest.has(login) || at >= newest.get(login).at) { + newest.set(login, { at, body: String(body) }); + } + } + return [...newest] + .filter(([, { body }]) => HUMAN_VETO.test(body)) + .map(([login]) => login) + .sort(); +} + +// One REST issue comment, review or review comment as a vetoers item. +export const fromRest = (row) => ({ + login: String(row?.user?.login ?? ""), + isUser: row?.user?.type === "User", + association: row?.author_association, + at: String(row?.submitted_at ?? row?.created_at ?? ""), + body: String(row?.body ?? ""), +}); + +// Why the pull request's `body` and discussion `items` hold it; empty when +// nothing does. +export function holds(body, items) { + const reasons = []; + if (BODY_HOLD.test(String(body ?? ""))) { + reasons.push("its body holds it (a line opening with 'Hold:', or 'do not merge'); removing that text lifts it"); + } + const vetoes = vetoers(items); + if (vetoes.length) { + reasons.push(`${vetoes.join(", ")} asked not to merge it; a later comment by the same person lifts it, then re-run ci-status`); + } + return reasons; +} + +export function main(argv = process.argv.slice(2)) { + const [number, bodyFile, discussionFile] = argv; + try { + if (!/^[1-9][0-9]*$/.test(String(number)) || !bodyFile || !discussionFile) { + throw new Error("usage: merge-holds.mjs "); + } + const items = readFileSync(discussionFile, "utf8") + .split("\n") + .filter((line) => line.trim()) + .map((line) => fromRest(JSON.parse(line))); + const reasons = holds(readFileSync(bodyFile, "utf8"), items); + for (const reason of reasons) { + console.log(`::error::#${number}: ${reason.replaceAll("%", "%25")}.`); + } + if (!reasons.length) { + console.log(`#${number}: no body hold and no member veto.`); + } + return reasons.length ? 1 : 0; + } catch (error) { + console.log(`::error::merge-holds: ${String(error?.message ?? error).replaceAll(/[\r\n%]/g, " ")}`); + return 2; + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + process.exitCode = main(); +} diff --git a/.github/workflows/pr-require-checks.yml b/.github/workflows/pr-require-checks.yml index 0c0ee8ddc0..b711a34209 100644 --- a/.github/workflows/pr-require-checks.yml +++ b/.github/workflows/pr-require-checks.yml @@ -3052,9 +3052,9 @@ jobs: # (`rate-limit-wait-seconds` on check-contract and aggregate-results), 45 s # of own-gate retries, 7 s of status-write retries and the 90 s # `rerun-wait-seconds` wait, about four and a half minutes, plus up to - # 15 s of retry sleeps for each of the hold-lift step's three reads and - # the review-lane step's four, under two minutes. - timeout-minutes: 7 + # 15 s of retry sleeps for each of the hold-lift step's three reads, the + # review-lane step's four and the lanes-hold step's five, three minutes. + timeout-minutes: 9 permissions: # `yield-to-full-run` lists this workflow's runs on the head SHA, and # `rerun-contract-only-siblings` re-runs the red contract-only ones once @@ -3549,6 +3549,73 @@ jobs: fi [ "$held" -eq 0 ] || exit 1 echo "#$pr carries neither 'do-not-merge' nor '$human_gated'." + # A LANES APP PULL REQUEST IS HELD BY ITS TEXT TOO. pr-merge.yml's + # enable-auto-merge job arms native auto-merge on lanes App pull + # requests, which then merge without pr-merge's gate, so this step fails + # on the two text holds that gate honours: a body hold and a member's + # veto comment, by the rules in .github/scripts/pr-merge/merge-holds.mjs, + # which this step runs as the base commit has it so a pull request cannot + # rewrite its own hold. On a pull request event and in the merge queue + # (the pull request named in the group's ref, as the step above reads + # it), the body, comments, reviews and inline comments are read live, + # never from the payload a re-run replays. A body edit re-runs this job; + # a comment does not, so a veto posted while armed is enforced when the + # queue builds the group, and after a veto is lifted ci-status needs a + # re-run. Only the lanes App's pull requests are read, by numeric id + # (pr-pipeline.yaml merge.agent-identity, as trusted-actors.json lists + # it); every other pull request merges through pr-merge's gate or a + # person. An unreadable pull request, module or discussion fails closed. + # Pinned by scripts/ci-lanes-pr-hold.test.sh, which runs this body. + - name: Fail a lanes App pull request its body or a member holds + if: ${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }} + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_SHA: ${{ github.event.merge_group.base_sha || github.event.pull_request.base.sha }} + run: | + lanes_app_id=337595936 + pr=$PR_NUMBER + if [ "$GITHUB_EVENT_NAME" = merge_group ]; then + pr=$(sed -nE 's#^(refs/heads/)?gh-readonly-queue/.+/pr-([0-9]+)-[0-9a-f]{40}$#\2#p' <<<"$GITHUB_REF") + if [ -z "$pr" ]; then + echo "::error::cannot read a pull request number from the merge-group ref '$GITHUB_REF'." + exit 1 + fi + fi + if [[ ! $pr =~ ^[1-9][0-9]*$ ]]; then + echo "::error::the pull request number '${pr//%/%25}' is not a number." + exit 1 + fi + if [[ ! $BASE_SHA =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::the base commit '$BASE_SHA' is not a commit SHA." + exit 1 + fi + read_api() { + local attempt body + for attempt in 1 2 3; do + if body=$(gh api "${@:2}"); then + printf '%s\n' "$body" + return 0 + fi + [ "$attempt" -lt 3 ] || { echo "::error::could not read $1; re-run this run." >&2; return 1; } + sleep $((attempt * 5)) + done + } + pull=$(read_api "#$pr" "repos/$GITHUB_REPOSITORY/pulls/$pr") || exit 1 + if ! jq -e --argjson id "$lanes_app_id" '.user.id == $id and .user.type == "Bot"' <<<"$pull" >/dev/null; then + echo "#$pr is not the lanes App's; pr-merge's gate or a person reads its holds." + exit 0 + fi + module=.github/scripts/pr-merge/merge-holds.mjs + read_api "$module at $BASE_SHA" -H 'Accept: application/vnd.github.raw+json' \ + "repos/$GITHUB_REPOSITORY/contents/$module?ref=$BASE_SHA" >"$RUNNER_TEMP/merge-holds.mjs" || exit 1 + jq -r '.body // ""' <<<"$pull" >"$RUNNER_TEMP/pr-body.txt" + : >"$RUNNER_TEMP/discussion.jsonl" + for kind in "issues/$pr/comments" "pulls/$pr/reviews" "pulls/$pr/comments"; do + rows=$(read_api "the $kind of #$pr" "repos/$GITHUB_REPOSITORY/$kind?per_page=100" --paginate --jq '.[] | @json') || exit 1 + [ -z "$rows" ] || printf '%s\n' "$rows" >>"$RUNNER_TEMP/discussion.jsonl" + done + node "$RUNNER_TEMP/merge-holds.mjs" "$pr" "$RUNNER_TEMP/pr-body.txt" "$RUNNER_TEMP/discussion.jsonl" # ORDERED SKIPS ARE CHECKED HERE. A test lane `test-`, or `lint-shell` # on its `run_shell` row, skips as a job when `select-tests`'s `run_` # row says the change gives it no work. Its diff --git a/AGENTS.md b/AGENTS.md index ad3f5fc9c5..9c991488f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,8 @@ Title every pull request in Conventional Commits form, `[()]: --add-label do-not-merge`) before writing any explanation. "Do not merge" in the body -or a comment is not enforced by `ci-status`, which reads only the label; the babysit merge gate +or a comment is not enforced by `ci-status`, which reads only the label, except on a lanes App pull +request, where it also fails on a body hold or a member's veto comment; the babysit merge gate still respects a body hold and human comments, so never override one. The hold convention is in `docs/conventions/loop-lane/README.md`. diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index ed923fb6ad..e9def14d61 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -1031,6 +1031,14 @@ missed. The token broker mints `merge` only for `pr-merge.yml`, and outside the listed human's run, so an issue run the lanes App started is armed by the next scheduled run. While `merge.report-only` is true it reports each decision and writes nothing. +`ci-status` enforces the same two text holds on every lanes App pull request, so one added after +arming stops the merge without waiting for this job: its step `Fail a lanes App pull request its +body or a member holds` runs the base commit's `.github/scripts/pr-merge/merge-holds.mjs`, the +module `enable-auto-merge.mjs` arms by, over the live body and discussion, on each pull request +event and in the merge queue, where a red group removes the pull request. A body edit re-runs +`ci-status` at once. A comment does not, so a veto posted while armed takes effect when the queue +builds the group, and after a person lifts one with a later comment, `ci-status` needs a re-run. + ### Dependabot pull requests No Dependabot pull request waits on a human diff --git a/scripts/ci-lanes-pr-hold.test.sh b/scripts/ci-lanes-pr-hold.test.sh new file mode 100755 index 0000000000..4621579a74 --- /dev/null +++ b/scripts/ci-lanes-pr-hold.test.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# Runs the `Fail a lanes App pull request its body or a member holds` step body +# of .github/workflows/pr-require-checks.yml's ci-status under the shell Actions +# gives a step with no `shell:` (bash -e), against stub `gh` and `sleep`. +# +# Native auto-merge (pr-merge.yml's enable-auto-merge job) merges a lanes App +# pull request without pr-merge's gate, so ci-status holds it on the two text +# holds that gate honours: a body hold (a `Hold:` line or "do not merge") and +# a member's newest comment saying not to merge. It reads them live, on a pull +# request event and in the merge queue, runs the base commit's +# merge-holds.mjs, and fails closed when it cannot tell. +# test-scope: .github/workflows/pr-require-checks.yml .github/scripts/pr-merge/merge-holds.mjs +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKFLOW="$ROOT/.github/workflows/pr-require-checks.yml" +MODULE="$ROOT/.github/scripts/pr-merge/merge-holds.mjs" + +# shellcheck source=lib/test-harness.sh +. "$ROOT/scripts/lib/test-harness.sh" + +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +NAME='Fail a lanes App pull request its body or a member holds' +step="$(awk -v name=" - name: $NAME" '$0 == name { on = 1; print; next } + on && /^ - / { exit } + on { print }' "$WORKFLOW")" +awk '/^ run: \|$/ { on = 1; next } + on && /^ / { print substr($0, 11); next } + on && /^[[:blank:]]*$/ { print ""; next } + on { exit }' <<<"$step" >"$TMP_ROOT/body.sh" + +if [[ ! -s "$TMP_ROOT/body.sh" ]]; then + fail "no '$NAME' step with a run: | body in $WORKFLOW" + test_harness::report + exit 1 +fi +if [[ "$step" == *"if: \${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}"* ]]; then + ok "step runs on pull_request and merge_group" +else + fail "step lost its pull_request and merge_group condition" +fi +if [[ "$step" == *"continue-on-error"* ]]; then + fail "step must not continue on error: its exit code is the hold" +else + ok "step fails the job on its exit code" +fi +if [[ "$step" == *"github.event.pull_request.body"* ]]; then + fail "step reads the body from the event payload, which a re-run replays stale" +else + ok "step reads the body live" +fi +if [[ "$step" == *"BASE_SHA: \${{ github.event.merge_group.base_sha || github.event.pull_request.base.sha }}"* ]]; then + ok "step runs merge-holds.mjs from the base commit" +else + fail "step does not read merge-holds.mjs at the base commit" +fi +order="$(grep -nE "^ ci-status:$|^ - name: ($NAME|Aggregate lane results)$" "$WORKFLOW" | cut -d: -f1 | paste -sd' ')" +read -r job_line hold_line agg_line <<<"$order" +if [[ -n "${agg_line:-}" && "$job_line" -lt "$hold_line" && "$hold_line" -lt "$agg_line" ]]; then + ok "step sits in ci-status before the aggregate" +else + fail "step is not inside ci-status before the aggregate (lines: $order)" +fi +# The step names the lanes App by numeric id; it must be the trusted-actor +# entry for pr-pipeline.yaml's merge.agent-identity. +identity="$(sed -nE 's/^ agent-identity: (.+)$/\1/p' "$ROOT/docs/conventions/pr-pipeline.yaml")" +want_id="$(jq -r --arg login "$identity" '.. | objects | select(.login? == $login) | .id' "$ROOT/.github/standards/trusted-actors/trusted-actors.json")" +if [[ -n "$want_id" && "$(grep -c "lanes_app_id=$want_id\$" "$TMP_ROOT/body.sh")" -eq 1 ]]; then + ok "step's lanes App id is merge.agent-identity's ($identity, $want_id)" +else + fail "step's lanes App id is not $identity's trusted-actor id '$want_id'" +fi + +# The stub logs its arguments. A pull read prints STUB_PULL. A contents read +# of merge-holds.mjs prints the working tree's module, or answers 404 when +# STUB_NO_MODULE is set. A discussion read fails its first STUB_FAILS calls, +# then prints each row of STUB_ISSUE, STUB_REVIEWS or STUB_INLINE (JSON +# arrays) as one line, as `--jq '.[] | @json'` does. +mkdir -p "$TMP_ROOT/bin" +cat >"$TMP_ROOT/bin/gh" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$STUB_LOG" +rows() { jq -c '.[]' <<<"${1:-[]}"; } +discussion() { + [[ "$(grep -cE '/(comments|reviews)\?' "$STUB_LOG")" -gt "${STUB_FAILS:-0}" ]] || { echo 'gh: Server Error (HTTP 502)' >&2; exit 1; } +} +case "$*" in +*/contents/.github/scripts/pr-merge/merge-holds.mjs*) + [[ -z "${STUB_NO_MODULE:-}" ]] || { echo 'gh: Not Found (HTTP 404)' >&2; exit 1; } + cat "$STUB_MODULE" + ;; +*/issues/*/comments\?*) discussion; rows "${STUB_ISSUE:-}" ;; +*/pulls/*/reviews\?*) discussion; rows "${STUB_REVIEWS:-}" ;; +*/pulls/*/comments\?*) discussion; rows "${STUB_INLINE:-}" ;; +*/pulls/*) printf '%s\n' "$STUB_PULL" ;; +*) exit 1 ;; +esac +EOF +printf '#!/usr/bin/env bash\n' >"$TMP_ROOT/bin/sleep" +chmod +x "$TMP_ROOT/bin/gh" "$TMP_ROOT/bin/sleep" + +SHA=0123456789abcdef0123456789abcdef01234567 +BASE=89abcdef0123456789abcdef0123456789abcdef +QUEUE="refs/heads/gh-readonly-queue/main/pr-7-$SHA" +LANES='{"id":337595936,"login":"melodic-automation-lanes[bot]","type":"Bot"}' +pull() { jq -nc --argjson user "${2:-$LANES}" --arg body "$1" '{number: 7, user: $user, body: $body}'; } +comment() { jq -nc --arg login "$1" --arg type "${2:-User}" --arg assoc "$3" --arg at "$4" --arg body "$5" \ + '{user: {login: $login, type: $type}, author_association: $assoc, created_at: $at, body: $body}'; } +review() { jq -nc --arg login "$1" --arg assoc "$2" --arg at "$3" --arg body "$4" \ + '{user: {login: $login, type: "User"}, author_association: $assoc, submitted_at: $at, body: $body}'; } + +# expect