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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changes/pr-pipeline/fix-9449-native-lane-holds-cc7e4173.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- `ci-status` and `enable-auto-merge` hold a lanes App pull request that changes the marketplace manifest (until a trusted approval of the head) or a merge-gate cost path (until the owner's linked cost decision).
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- A test keeps the native auto-merge cost paths and manifest holds in step with `babysit_risk`.
30 changes: 24 additions & 6 deletions .github/scripts/pr-merge/enable-auto-merge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
// wrote its Hold: line can add one. The rule is merge-holds.pathHold, which
// ci-status also runs on every lanes App pull request event and merge
// group, so a push after arming is held without waiting for this job.
// - Its head changes no cost path, unless its body links the owner's cost
// decision: merge-holds.costHold and costDecision, which ci-status runs too.
// - 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.
// - pr-merge's gate would take its verifier verdict at the head just read:
Expand Down Expand Up @@ -52,19 +54,22 @@
// 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
// TRUSTED_ACTORS the base trusted-actors.json, for the owner of a cost
// decision (default .base/.github/standards/trusted-actors/trusted-actors.json)
// EVENT_NAME the run's event; only READER_EVENTS read verdicts and arm
// BABYSIT_VERIFY_*, BABYSIT_RUN_CACHE the reader's Actions token (the
// job's GITHUB_TOKEN), saved listing, head-ref map and
// run cache, as the gate's
// GITHUB_API_URL
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
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";
import { BODY_HOLD, pathHold, vetoers } from "./merge-holds.mjs";
import { BODY_HOLD, costDecision, costHold, humansOf, pathHold, vetoers } from "./merge-holds.mjs";

export { BODY_HOLD, HUMAN_VETO } from "./merge-holds.mjs";

Expand Down Expand Up @@ -161,6 +166,17 @@ export function discussionHold(detail) {
return vetoes.length ? `${vetoes.join(", ")} asked not to merge it` : null;
}

// The trusted-actor list's people; an unreadable list names none, so no cost
// decision clears.
function trustedHumans(env) {
try {
const { actors } = JSON.parse(readFileSync(env.TRUSTED_ACTORS || ".base/.github/standards/trusted-actors/trusted-actors.json", "utf8"));
return humansOf(actors);
} catch {
return new Set();
}
}

async function changedFiles(github, slug, number) {
const paths = [];
for (let page = 1; page <= MAX_FILE_PAGES; page += 1) {
Expand Down Expand Up @@ -245,7 +261,7 @@ export function decide(pull, { bot, humanGated, holdPaths = null }, detail) {
if (detail === null) {
return { action: "none", reason: "its discussion did not read" };
}
const vetoed = pathHold(detail.files, holdPaths) ?? discussionHold(detail);
const vetoed = pathHold(detail.files, holdPaths) ?? costHold(detail.files, detail.costDecision) ?? discussionHold(detail);
if (vetoed !== null) {
return { action: armed ? "disarm" : "none", reason: vetoed };
}
Expand Down Expand Up @@ -314,7 +330,9 @@ export async function main({ env = process.env, github, verify } = {}) {
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) };
const files = await changedFiles(github, `${repo[1]}/${repo[2]}`, pull.number);
const decision = costHold(files, null) ? await costDecision(github, repo[0], pull.number, pull.body, trustedHumans(env)) : null;
detail = { ...detail, files, costDecision: decision };
}
} catch (error) {
console.log(`enable-auto-merge: #${pull.number} discussion did not read: ${escapeCommand(error?.message ?? error)}`);
Expand All @@ -339,12 +357,12 @@ export async function main({ env = process.env, github, verify } = {}) {
}
if (action === "none") {
if (reason !== "not the lanes App's") {
console.log(`enable-auto-merge: #${pull.number} left: ${reason}`);
console.log(`enable-auto-merge: #${pull.number} left: ${escapeCommand(reason)}`);
}
continue;
}
if (!write) {
console.log(`enable-auto-merge: #${pull.number} would ${action} (report-only): ${reason}`);
console.log(`enable-auto-merge: #${pull.number} would ${action} (report-only): ${escapeCommand(reason)}`);
continue;
}
try {
Expand All @@ -353,7 +371,7 @@ export async function main({ env = process.env, github, verify } = {}) {
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}`);
console.log(`enable-auto-merge: #${pull.number} disarmed: ${escapeCommand(reason)}`);
}
} catch (error) {
failed += 1;
Expand Down
71 changes: 68 additions & 3 deletions .github/scripts/pr-merge/enable-auto-merge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
main,
verifyHold,
} from "./enable-auto-merge.mjs";
import { VERIFY_PATH, verdictHold } from "./merge-holds.mjs";
import { COMMENT_EDITED_QUERY, PULL_OPENED_QUERY, VERIFY_PATH, verdictHold } from "./merge-holds.mjs";
import { VERIFY_PATH as RESOLVER_VERIFY_PATH } from "../pr-verify/resolve-pr.mjs";
import { decide as decideOutcome } from "../work-resolve-issue/report-outcome.mjs";

Expand Down Expand Up @@ -74,6 +74,15 @@ const said = (login, body, at, association = "OWNER", typename = "User") => ({
authorAssociation: association,
});
const PASSED = { refusal: null, head: HEAD };
const COST_LINK = `https://github.com/${REPO}/issues/1040#issuecomment-7`;
const COST_COMMENT = {
issue_url: `https://api.github.com/repos/${REPO}/issues/1040`,
user: { login: "kyle-sexton", id: 5, type: "User" },
performed_via_github_app: null,
body: "**Decision recorded.** Cost: approve it.",
node_id: "IC_7",
created_at: "2026-10-22T12:00:00Z",
};
const labels = (...names) => ({ totalCount: names.length, nodes: names.map((name) => ({ name })) });

function world(pulls, { failArm = false } = {}) {
Expand All @@ -85,7 +94,20 @@ function world(pulls, { failArm = false } = {}) {
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);
}
// The cost decision reads (merge-holds.costDecision): the owner's comment
// 7 on pull request 40's closing issue, and each pull request closes its own
// number plus 1000.
if (method === "GET" && path === "/repos/melodic-software/claude-code-plugins/issues/comments/7") {
return COST_COMMENT;
}
assert.equal(`${method} ${path}`, "POST /graphql");
if (body.query === PULL_OPENED_QUERY) {
const nodes = [{ number: body.variables.number + 1000, repository: { nameWithOwner: REPO } }];
return { data: { repository: { pullRequest: { createdAt: "2026-10-22T10:00:00Z", closingIssuesReferences: { totalCount: 1, nodes } } } } };
}
if (body.query === COMMENT_EDITED_QUERY) {
return { data: { node: { lastEditedAt: null } } };
}
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 } } } };
Expand All @@ -112,9 +134,9 @@ function world(pulls, { failArm = false } = {}) {
};
const writes = () =>
calls
.filter(({ method, body }) => method === "POST" && body.query !== PULLS_QUERY && body.query !== DETAIL_QUERY)
.filter(({ method, body }) => method === "POST" && ![PULLS_QUERY, DETAIL_QUERY, PULL_OPENED_QUERY, COMMENT_EDITED_QUERY].includes(body.query))
.map(({ body }) => ({ mutation: body.query === ARM_MUTATION ? "arm" : body.query === DISARM_MUTATION ? "disarm" : "?", ...body.variables }));
return { github, writes, verify, verified };
return { github, writes, verify, verified, calls };
}

async function run(pulls, env = {}, options = {}) {
Expand Down Expand Up @@ -362,6 +384,49 @@ test("a pull request whose head now changes a hold path is not armed, or is disa
]);
});

// The gate scores the marketplace manifest HIGH (babysit_risk.is_manifest_path),
// so it is a hold path like every plugin manifest.
test("a pull request whose head changes the marketplace manifest is not armed, or is disarmed", () => {
const options = { bot: LANES.login, humanGated: "needs-human", holdPaths: [] };
const files = ["docs/a.md", ".claude-plugin/marketplace.json"];
assert.deepEqual(decide(pull(1), options, { ...detail(), files, verify: PASSED }), {
action: "none",
reason: "it changes .claude-plugin/marketplace.json, a hold path",
});
assert.equal(decide(pull(1, { autoMergeRequest: {} }), options, { ...detail(), files, verify: PASSED }).action, "disarm");
});

// The gate holds a cost path (babysit_risk.COST_PATHS) until the body links
// the owner's cost decision (babysit_merge_policy.cost_decision).
test("a cost path holds until the body links the owner's cost decision", async () => {
const options = { bot: LANES.login, humanGated: "needs-human", holdPaths: [] };
const files = ["infra/Pulumi.prod.yaml"];
assert.deepEqual(decide(pull(1), options, { ...detail(), files, verify: PASSED }), {
action: "none",
reason: "it changes infra/Pulumi.prod.yaml, a cost path",
});
assert.equal(decide(pull(1, { autoMergeRequest: {} }), options, { ...detail(), files, verify: PASSED }).action, "disarm");
assert.equal(decide(pull(1), options, { ...detail(), files, costDecision: COST_LINK, verify: PASSED }).action, "arm");

const dir = mkdtempSync(join(tmpdir(), "enable-auto-merge-"));
writeFileSync(join(dir, "actors.json"), JSON.stringify({ actors: [{ kind: "human", login: "kyle-sexton", id: 5 }] }));
const cost = [{ filename: "plugins/multi-agent/reference/defaults.yaml" }];
const decided = `Closes #1040\n\nCost decision: ${COST_LINK}`;
const { writes } = await run(
[pull(40, { files: cost, body: decided }), pull(41, { files: cost }), pull(42, { files: cost, body: decided })],
{ TRUSTED_ACTORS: join(dir, "actors.json") },
);
// Pull request 41 links no decision; 42 closes a different issue, so pull
// request 40's decision does not cover it.
assert.deepEqual(writes, [{ mutation: "arm", id: "PR_40", head: HEAD }]);
// A changed file's name cannot start a workflow command on its own line.
const { lines } = await run([pull(43, { files: [{ filename: "x\n::warning::forged/main.tf" }] })]);
assert.ok(lines.some((line) => line.includes("x ::warning::forged/main.tf, a cost path")));
assert.ok(!lines.some((line) => /(^|\n)::warning::/.test(line)));
// Not on the trusted-actor list, the commenter decides nothing.
assert.deepEqual((await run([pull(40, { files: cost, body: decided })], { TRUSTED_ACTORS: "/nonexistent/actors.json" })).writes, []);
});

// ci-status runs merge-holds.mjs on the live files with the base config's
// denied paths, by the same pathHold that decides arming above.
test("merge-holds.mjs holds a changed or renamed-from hold path from the command line", () => {
Expand Down
Loading
Loading