From 36b37e7b43e576bdbe9ac5e96a606c12230da72e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:17:50 -0400 Subject: [PATCH 1/5] fix(pr-pipeline): hold native lane PRs on marketplace and cost paths ci-status and enable-auto-merge now hold a lanes App pull request that changes the root marketplace manifest (a hold path, lifted by a trusted approval of the head) or a merge-gate cost path. The cost hold lifts only on the owner's unedited `**Decision recorded.** Cost:` comment linked from the body, read as the gate's cost_decision reads it, with the owner a person on the trusted-actor list as ci-status's cost-only review step names one. A Python test keeps the JS cost globs and matches in step with babysit_risk.COST_PATHS and is_manifest_path. Co-Authored-By: Claude Opus 5.5 --- .../scripts/pr-merge/enable-auto-merge.mjs | 24 ++- .../pr-merge/enable-auto-merge.test.mjs | 65 ++++++- .github/scripts/pr-merge/merge-holds.mjs | 148 +++++++++++++-- .github/scripts/pr-merge/merge-holds.test.mjs | 175 ++++++++++++++++++ .github/workflows/pr-require-checks.yml | 5 +- docs/conventions/pr-pipeline/README.md | 11 +- .../scripts/tests/test_merge_holds_sync.py | 102 ++++++++++ 7 files changed, 509 insertions(+), 21 deletions(-) create mode 100644 .github/scripts/pr-merge/merge-holds.test.mjs create mode 100644 plugins/source-control/skills/babysit-prs/scripts/tests/test_merge_holds_sync.py diff --git a/.github/scripts/pr-merge/enable-auto-merge.mjs b/.github/scripts/pr-merge/enable-auto-merge.mjs index c764ce748c..4445d9e893 100755 --- a/.github/scripts/pr-merge/enable-auto-merge.mjs +++ b/.github/scripts/pr-merge/enable-auto-merge.mjs @@ -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: @@ -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"; @@ -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) { @@ -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 }; } @@ -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)}`); diff --git a/.github/scripts/pr-merge/enable-auto-merge.test.mjs b/.github/scripts/pr-merge/enable-auto-merge.test.mjs index 98f7e73f22..3da0b9336a 100644 --- a/.github/scripts/pr-merge/enable-auto-merge.test.mjs +++ b/.github/scripts/pr-merge/enable-auto-merge.test.mjs @@ -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"; @@ -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 } = {}) { @@ -85,7 +94,19 @@ 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 issue 1040, and pull request N closes issue N + 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 } } } }; @@ -112,9 +133,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 = {}) { @@ -362,6 +383,44 @@ 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 issue 1042, so a decision on 1040 does not cover it. + assert.deepEqual(writes, [{ mutation: "arm", id: "PR_40", head: HEAD }]); + // 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", () => { diff --git a/.github/scripts/pr-merge/merge-holds.mjs b/.github/scripts/pr-merge/merge-holds.mjs index 6edc296313..4cd0049839 100755 --- a/.github/scripts/pr-merge/merge-holds.mjs +++ b/.github/scripts/pr-merge/merge-holds.mjs @@ -16,6 +16,9 @@ // merge.diff-check.denied-paths plus HOLD_PATH_FLOOR. A person on the // trusted-actor list lifts it by approving the head: their newest review of // the head that approves, requests changes or was dismissed must approve. +// - A cost path (COST_PATHS): a changed file the gate holds for the owner's +// cost decision. An approval does not lift it; only the decision the gate +// reads does (costDecision), linked from the body. // - No verifier pass, when a verdicts file is given (ci-status gives one in // the merge queue only): the head merges on a PASS or PASS-NOTES that // pr-verify.yml uploaded from a completed, successful run on main @@ -34,7 +37,8 @@ // renamed-from path; the denied-paths file the config's // merge.diff-check.denied-paths as JSON; the trusted-actors file // trusted-actors.json. Each hold prints as an ::error:: and exits 1; no hold -// exits 0. +// exits 0. A cost path with a decision link in the body reads the decision +// with GH_TOKEN. import { readFileSync } from "node:fs"; import { posix } from "node:path"; import process from "node:process"; @@ -54,13 +58,26 @@ export const FORCED_DENIED_PATHS = Object.freeze([ ".github/standards/trusted-actors/trusted-actors.json", ]); // Every hold path check adds these: the forced paths, the rest of the merge -// gate's HIGH floor (babysit_risk.HIGH_FLOOR), and every plugin manifest, a -// path-wide stand-in for the gate's executable-key manifest check. +// gate's HIGH floor (babysit_risk.HIGH_FLOOR), and every plugin manifest and +// the marketplace manifest (babysit_risk.is_manifest_path), a path-wide +// stand-in for the gate's manifest checks. export const HOLD_PATH_FLOOR = Object.freeze([ ...FORCED_DENIED_PATHS, "**/CODEOWNERS", "**/.gitmodules", "**/.claude-plugin/plugin.json", + ".claude-plugin/marketplace.json", +]); +// babysit_risk.COST_PATHS, matched case-folded as its glob_match does; +// test_merge_holds_sync.py checks the two agree. +export const COST_PATHS = Object.freeze([ + "plugins/multi-agent/reference/defaults.yaml", + "**/Pulumi.yaml", + "**/Pulumi.*.yaml", + "**/*.tf", + "**/*.tfvars", + "**/*.bicep", + "**/*.bicepparam", ]); // The members whose newest item with text vetoes the merge, sorted. Each item @@ -116,6 +133,97 @@ export function pathHold(files, holdPaths) { return held === undefined ? null : `it changes ${held}, a hold path`; } +// Why the changed `files` hold it for the owner's cost decision, or null when +// none is a cost path or `decision` (costDecision's link) clears it. As +// glob_match does, a glob also covers everything under it. +export function costHold(files, decision) { + const held = (Array.isArray(files) ? files : []).find((path) => + COST_PATHS.some((glob) => [glob, `${glob}/**`].some((each) => matchesHoldPath(String(path).toLowerCase(), each.toLowerCase()))), + ); + return held === undefined || decision ? null : `it changes ${held}, a cost path`; +} + +// babysit_merge_policy COST_DECISION_LINK, DECISION_LINE and HTML_COMMENT. +const DECISION_LINK = /https:\/\/github\.com\/([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+)\/(?:issues|pull)\/([0-9]+)#issuecomment-([0-9]+)/g; +const DECISION_LINE = /^\*\*Decision recorded\.\*\*[ \t]+Cost:/; +const HTML_COMMENT = /|$)/g; +export const PULL_OPENED_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { createdAt closingIssuesReferences(first: 2) { totalCount nodes { number repository { nameWithOwner } } } } + } +}`; +export const COMMENT_EDITED_QUERY = "query($id: ID!) { node(id: $id) { ... on IssueComment { lastEditedAt } } }"; + +// The first comment link in `body`, outside HTML comments, that records the +// owner's cost decision for pull request `number` of `repo`, else null: the +// rule of babysit_merge_policy.cost_decision, with the owner a person whose +// id is in `humans` (trusted-actors.json), as ci-status's cost-only review +// hold step names one. The comment is on the pull request, or on its one +// closing issue in `repo` and newer than the pull request; by a User, not +// through an App; never edited; its first line `**Decision recorded.** Cost:`. +// `github(method, path, body)` reads; a read that fails clears nothing. +export async function costDecision(github, repo, number, body, humans) { + const links = [...String(body ?? "").replaceAll(HTML_COMMENT, "").matchAll(DECISION_LINK)].filter( + (link) => `${link[1]}/${link[2]}`.toLowerCase() === String(repo).toLowerCase(), + ); + if (!links.length) { + return null; + } + const [owner, name] = repo.split("/"); + let pull; + try { + pull = (await github("POST", "/graphql", { query: PULL_OPENED_QUERY, variables: { owner, name, number } }))?.data?.repository?.pullRequest; + } catch { + return null; + } + const opened = Date.parse(pull?.createdAt); + const closing = pull?.closingIssuesReferences; + const issue = + closing?.totalCount === 1 && closing.nodes?.length === 1 && String(closing.nodes[0]?.repository?.nameWithOwner).toLowerCase() === repo.toLowerCase() + ? closing.nodes[0].number + : null; + for (const link of links) { + const target = Number(link[3]); + if (target !== number && target !== issue) { + continue; + } + try { + const comment = await github("GET", `/repos/${repo}/issues/comments/${link[4]}`); + if ( + !String(comment?.issue_url ?? "").endsWith(`/issues/${target}`) || + comment.user?.type !== "User" || + comment.performed_via_github_app != null || + !humans.has(comment.user.id) || + !DECISION_LINE.test(String(comment.body ?? "").trimStart()) || + (target !== number && !(Date.parse(comment.created_at) > opened)) + ) { + continue; + } + const node = (await github("POST", "/graphql", { query: COMMENT_EDITED_QUERY, variables: { id: String(comment.node_id) } }))?.data?.node; + if (node && "lastEditedAt" in node && node.lastEditedAt === null) { + return link[0]; + } + } catch { + // An unreadable comment clears nothing. + } + } + return null; +} + +// A GitHub REST and GraphQL reader on fetch, for costDecision. +const fetchGitHub = (env) => async (method, path, body) => { + const response = await fetch(`${env.GITHUB_API_URL || "https://api.github.com"}${path}`, { + method, + headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${env.GH_TOKEN}`, "X-GitHub-Api-Version": "2022-11-28" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const json = response.ok ? await response.json() : null; + if (json === null || json.errors?.length) { + throw new Error(`${method} ${path} failed with status ${response.status}`); + } + return json; +}; + // The config's denied paths, or null unless a list of strings; pathHold adds // the floor. export function holdPathsOf(denied) { @@ -145,8 +253,9 @@ export function pathLifters(items, humans, head) { // Why the pull request's `body`, discussion `items` and changed `files` hold // it; empty when nothing does. `lifters` approved the head, which lifts a hold -// path but not an unreadable file list or hold-path list. -export function holds(body, items, files, holdPaths, lifters = []) { +// path but not an unreadable file list or hold-path list, nor a cost path, +// which only the owner's `decision` lifts. +export function holds(body, items, files, holdPaths, lifters = [], decision = null) { const reasons = []; const pathReason = pathHold(files, holdPaths); const readable = Array.isArray(files) && Array.isArray(holdPaths); @@ -157,6 +266,12 @@ export function holds(body, items, files, holdPaths, lifters = []) { : pathReason, ); } + const cost = costHold(files, decision); + if (cost) { + reasons.push( + `${cost}; the owner's unedited comment opening '**Decision recorded.** Cost:' on this pull request or its closing issue, linked from the body, lifts it`, + ); + } 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"); } @@ -203,7 +318,11 @@ export function verdictHold(records, repo, branch = "main") { // workflow command on a new line. const escape = (text) => String(text).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A"); -export function main(argv = process.argv.slice(2)) { +// The numeric ids of trusted-actors.json's people. +export const humansOf = (actors) => + new Set(actors.filter((actor) => actor?.kind === "human" && Number.isInteger(actor?.id)).map(({ id }) => id)); + +export async function main(argv = process.argv.slice(2), { env = process.env, github = fetchGitHub(env) } = {}) { const [number, bodyFile, discussionFile, filesFile, deniedFile, actorsFile, head, verdictsFile] = argv; try { if ( @@ -227,17 +346,22 @@ export function main(argv = process.argv.slice(2)) { if (!Array.isArray(actors)) { throw new Error("the trusted-actor list has no actors"); } - const humans = new Set( - actors.filter((actor) => actor?.kind === "human" && Number.isInteger(actor?.id)).map(({ id }) => id), - ); + const humans = humansOf(actors); const lifters = pathLifters(items, humans, head); - const reasons = holds(readFileSync(bodyFile, "utf8"), items, files, holdPaths, lifters); + const body = readFileSync(bodyFile, "utf8"); + const repo = String(env.GITHUB_REPOSITORY); + const decision = + costHold(files, null) && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) ? await costDecision(github, repo, Number(number), body, humans) : null; + if (decision) { + console.log(`#${number}: ${decision} records the owner's cost decision, which lifts its cost hold.`); + } + const reasons = holds(body, items, files, holdPaths, lifters, decision); const held = pathHold(files, holdPaths); if (held && lifters.length && Array.isArray(holdPaths)) { console.log(`#${number}: ${lifters.join(", ")} approved this head, which lifts its hold path (${escape(held)}).`); } if (verdictsFile) { - const unverified = verdictHold(lines(verdictsFile).map((line) => JSON.parse(line)), process.env.GITHUB_REPOSITORY); + const unverified = verdictHold(lines(verdictsFile).map((line) => JSON.parse(line)), env.GITHUB_REPOSITORY); if (!unverified) { console.log(`#${number}: no verifier hold: pr-verify passed this head.`); } else if (lifters.length) { @@ -262,5 +386,5 @@ export function main(argv = process.argv.slice(2)) { } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - process.exitCode = main(); + process.exitCode = await main(); } diff --git a/.github/scripts/pr-merge/merge-holds.test.mjs b/.github/scripts/pr-merge/merge-holds.test.mjs new file mode 100644 index 0000000000..05916fa81a --- /dev/null +++ b/.github/scripts/pr-merge/merge-holds.test.mjs @@ -0,0 +1,175 @@ +// Expected values come from the issue that added these holds and the gate's +// own rules: the merge gate scores a marketplace manifest change HIGH +// (babysit_risk.is_manifest_path) and holds a change to a cost path +// (babysit_risk.COST_PATHS) until the owner's cost decision, which +// babysit_merge_policy.cost_decision reads and test_babysit_merge_cost.py +// pins case by case. ci-status names its owner by the trusted-actor list, as +// its cost-only review hold step does, so a review approval lifts a hold path +// but never a cost hold. +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { COMMENT_EDITED_QUERY, PULL_OPENED_QUERY, costDecision, costHold, main } from "./merge-holds.mjs"; + +const REPO = "owner/repo"; +const LINK = "https://github.com/owner/repo/issues/40#issuecomment-7"; +const PR_CREATED = "2026-10-22T10:00:00Z"; +const HUMANS = new Set([5]); +const HEAD = "0123456789abcdef0123456789abcdef01234567"; + +const comment = (changes = {}) => ({ + issue_url: "https://api.github.com/repos/owner/repo/issues/40", + user: { login: "owner-person", id: 5, type: "User" }, + performed_via_github_app: null, + body: "**Decision recorded.** Cost: approve the larger runner.", + node_id: "IC_7", + created_at: "2026-10-22T12:00:00Z", + ...changes, +}); + +// A GitHub that answers the decision reads: the pull request's creation time +// and closing issues, comment 7, and its edit time. `fail` names a read that +// throws. +function world({ row = comment(), closing = [{ number: 40, repository: { nameWithOwner: REPO } }], node, created = PR_CREATED, fail } = {}) { + const reads = []; + const github = async (method, path, body) => { + const read = body?.query === PULL_OPENED_QUERY ? "pull" : body?.query === COMMENT_EDITED_QUERY ? "edited" : `${method} ${path}`; + reads.push(read); + if (read === fail) { + throw new Error(`${read} failed`); + } + if (read === "pull") { + assert.deepEqual(body.variables, { owner: "owner", name: "repo", number: 99 }); + return { data: { repository: { pullRequest: { createdAt: created, closingIssuesReferences: { totalCount: closing.length, nodes: closing } } } } }; + } + if (read === "edited") { + assert.deepEqual(body.variables, { id: "IC_7" }); + return { data: { node: node === undefined ? { lastEditedAt: null } : node } }; + } + assert.equal(read, "GET /repos/owner/repo/issues/comments/7"); + return row; + }; + return { github, reads }; +} + +const decide = (body = `Closes #40\n\nCost decision: ${LINK}\n`, options = {}) => { + const { github, reads } = world(options); + return costDecision(github, REPO, 99, body, HUMANS).then((link) => ({ link, reads })); +}; + +test("an owner decision on the closing issue, linked from the body, clears the cost hold", async () => { + const { link, reads } = await decide(); + assert.equal(link, LINK); + assert.deepEqual(reads, ["pull", "GET /repos/owner/repo/issues/comments/7", "edited"]); +}); + +test("one on the pull request itself clears with no time check", async () => { + const own = "https://github.com/owner/repo/pull/99#issuecomment-7"; + const row = comment({ issue_url: "https://api.github.com/repos/owner/repo/issues/99", created_at: null }); + assert.equal((await decide(own, { row, closing: [] })).link, own); +}); + +test("a body with no covering link reads nothing", async () => { + for (const body of ["Closes #40", LINK.replace("owner/repo", "evil/repo"), `Closes #40\n\n`, `Closes #40\n", _comment(7)), + "inside an unclosed HTML comment": (f"