Skip to content
Open
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/source-control/agent-a5e09147a92521857-c3fc6a4d.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- `cost_decision` binds an owner cost decision to one pull request and the head it decided. A `**Decision recorded.** Cost:` comment counts on the pull request, or on its linked issue (closing, else the one `Refs:` issue) when it names the pull request as `#<number>` (not `owner/repo#<number>`, `&#<number>` or `##<number>`); the newest on the pull request, else the newest on the issue, is the decision. It covers the head of the newest github-actions[bot] cost hold marker before it while that marker was never edited and no comment on the pull request was deleted since it was posted (an edited or unreadable one, or a deletion since, covers no head, and no older marker stands in), and a later head only when every change since is a merge of the base: the pull request's net change against its base branch, file by file, is the same at both. With no marker before it, it covers no head. A decision recorded on that issue for another change no longer clears the cost hold of a pull request that links the issue later, and a commit after the decision needs a new one.
2 changes: 1 addition & 1 deletion .github/scripts/pr-merge/enable-auto-merge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@ export async function main({ env = process.env, github, verify, room, sleep = (m
detail = (await github("POST", "/graphql", { query: DETAIL_QUERY, variables }))?.data?.repository?.pullRequest ?? null;
if (detail !== null) {
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;
const decision = costHold(files, null) ? await costDecision(github, repo[0], pull.number, pull.body, trustedHumans(env), pull.headRefOid) : null;
detail = { ...detail, files, costDecision: decision };
}
} catch (error) {
Expand Down
13 changes: 10 additions & 3 deletions .github/scripts/pr-merge/enable-auto-merge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
verifyHold,
} from "./enable-auto-merge.mjs";
import { GitHubError } from "../../actions/check-trusted-trigger/check-trusted-trigger.mjs";
import { COMMENT_EDITED_QUERY, PULL_OPENED_QUERY, VERIFY_PATH, verdictHold } from "./merge-holds.mjs";
import { COMMENT_DELETED_QUERY, 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 @@ -85,7 +85,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.",
body: "**Decision recorded.** Cost: approve it for #40.",
node_id: "IC_7",
created_at: "2026-10-22T12:00:00Z",
};
Expand Down Expand Up @@ -118,6 +118,10 @@ function world(pulls, { failArm = false, armErrors = {}, filesError = null, note
if (method === "GET" && path === "/repos/melodic-software/claude-code-plugins/issues/comments/7") {
return COST_COMMENT;
}
// Its decided head: a review lane's cost hold marker at the head, before it.
if (method === "GET" && /^\/repos\/melodic-software\/claude-code-plugins\/issues\/[0-9]+\/comments\?per_page=100&page=1$/.test(path)) {
return [{ user: { login: "github-actions[bot]", type: "Bot" }, created_at: "2026-10-22T11:00:00Z", body: `<!-- claude-lane-hold lane=claude-review sha=${HEAD} criteria=cost -->` }];
}
// The read-back pin, posted before an arm.
if (method === "POST" && String(body?.body ?? "").startsWith("<!-- babysit-merge-pin ")) {
pins.push({ path, body: body.body });
Expand All @@ -143,6 +147,9 @@ function world(pulls, { failArm = false, armErrors = {}, filesError = null, note
if (body.query === COMMENT_EDITED_QUERY) {
return { data: { node: { lastEditedAt: null } } };
}
if (body.query === COMMENT_DELETED_QUERY) {
return { data: { repository: { pullRequest: { timelineItems: { filteredCount: 0, nodes: [] } } } } };
}
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 Down Expand Up @@ -179,7 +186,7 @@ function world(pulls, { failArm = false, armErrors = {}, filesError = null, note
};
const writes = () =>
calls
.filter(({ method, path, body }) => method === "POST" && path === "/graphql" && ![PULLS_QUERY, DETAIL_QUERY, PULL_OPENED_QUERY, COMMENT_EDITED_QUERY, UNLABELED_QUERY].includes(body.query))
.filter(({ method, path, body }) => method === "POST" && path === "/graphql" && ![PULLS_QUERY, DETAIL_QUERY, PULL_OPENED_QUERY, COMMENT_EDITED_QUERY, COMMENT_DELETED_QUERY, UNLABELED_QUERY].includes(body.query))
.map(({ body }) => ({ mutation: body.query === ARM_MUTATION ? "arm" : body.query === DISARM_MUTATION ? "disarm" : "?", ...body.variables }));
return { github, writes, verify, verified, calls, notes, pins };
}
Expand Down
210 changes: 190 additions & 20 deletions .github/scripts/pr-merge/merge-holds.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
// key by key. The same approval lifts it.
// - 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.
// reads does (costDecision), linked from the body: on the pull request, or
// on its linked issue naming it, for the head it decided or a later one
// whose every change since is a base merge.
// - 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
Expand Down Expand Up @@ -422,20 +424,164 @@ const DECISION_LINK = /https:\/\/github\.com\/([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+)
const DECISION_LINE = /^\*\*Decision recorded\.\*\*[ \t]+Cost:/;
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 } } } }
pullRequest(number: $number) { createdAt baseRefName closingIssuesReferences(first: 2) { totalCount nodes { number repository { nameWithOwner } } } }
}
}`;
export const COMMENT_EDITED_QUERY = "query($id: ID!) { node(id: $id) { ... on IssueComment { lastEditedAt } } }";
export const COMMENT_DELETED_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) { timelineItems(itemTypes: [COMMENT_DELETED_EVENT], last: 100) { filteredCount nodes { ... on CommentDeletedEvent { createdAt } } } }
}
}`;

// Whether `text` names pull request `number` as `#<number>`: not part of a
// longer number, and not after a letter, digit, `_`, `#`, `&`, `/`, `.` or
// `-`, so neither `owner/repo#<number>` nor `&#<number>` counts
// (babysit_merge_policy.names_pull; the review lanes' hold step excludes the
// same characters but `.` and `-`).
export function namesPull(text, number) {
return Number.isSafeInteger(number) && new RegExp(`(?:^|[^A-Za-z0-9_#&/.-])#${number}(?:[^0-9]|$)`).test(String(text ?? ""));
}

// A review lane's cost hold marker, as ci-workflows apply-review-hold writes
// it on the first line of its hold comment: the head it held and its
// criteria. The base branch name a compare takes, as the hold step reads it.
const COST_MARKER = /^<!-- claude-lane-hold lane=[a-z-]+ sha=([0-9a-f]{40}) criteria=([a-z,-]+) -->(?:\n|$)/;
const BRANCH = /^(?!-)(?!.*\.\.)[A-Za-z0-9._/-]{1,255}$/;

// The newest cost hold marker github-actions[bot] posted among the pull
// request's `comments` before a decision made at `at`, as { sha, node, at }
// (the head it held, its comment's node id and created_at), else null
// (babysit_merge_policy.cost_marker; the first of two at one time wins, as
// in the review lanes' hold step). It decides a head only while never
// edited and while no comment was deleted since it was posted, which the
// caller reads.
export function costMarker(comments, at) {
const cutoff = Date.parse(at);
let found = null;
let latest = -Infinity;
for (const row of Array.isArray(comments) ? comments : []) {
const marker = row?.user?.login === "github-actions[bot]" ? COST_MARKER.exec(String(row.body ?? "")) : null;
const made = Date.parse(row?.created_at);
if (marker && marker[2].split(",").includes("cost") && made < cutoff && made > latest) {
found = { sha: marker[1], node: String(row.node_id ?? ""), at: row.created_at };
latest = made;
}
}
return found;
}

// Whether a COMMENT_DELETED_QUERY answer (its `data`) shows a comment on the
// pull request deleted at or after `at`, or cannot show that none was: no
// readable list, more deletions than it lists, or a time that does not parse
// (babysit_merge_policy.deleted_since). A deleted newer marker would
// otherwise make an older one the decided head.
export function deletedSince(answer, at) {
const when = Date.parse(at);
const items = answer?.repository?.pullRequest?.timelineItems;
const nodes = items?.nodes;
if (!Number.isFinite(when) || !Array.isArray(nodes) || !Number.isInteger(items.filteredCount) || items.filteredCount > nodes.length) {
return true;
}
return nodes.some((node) => {
const made = Date.parse(node?.createdAt);
return !Number.isFinite(made) || made >= when;
});
}

// Whether a COMMENT_EDITED_QUERY answer's node was never edited.
const unedited = (node) => Boolean(node) && "lastEditedAt" in node && node.lastEditedAt === null;

// Whether two compare answers (base...head) show the same net change of the
// pull request: the same filenames, each with the same status,
// previous_filename and patch. A list of 300 files or more (the compare
// API's cap), a file with no patch, a filename listed twice, or an answer
// with no file list is not the same (babysit_merge_policy.same_net_change).
export function sameNetChange(decided, current) {
const net = (compare) => {
const files = compare?.files;
if (!Array.isArray(files) || files.length >= 300) return null;
const byName = new Map();
for (const f of files) {
if (typeof f?.filename !== "string" || typeof f.patch !== "string" || byName.has(f.filename)) return null;
byName.set(f.filename, JSON.stringify([f.status ?? null, f.previous_filename ?? null, f.patch]));
}
return byName;
};
const [then, now] = [net(decided), net(current)];
return then !== null && now !== null && then.size === now.size && [...then].every(([name, value]) => now.get(name) === value);
}

// Every page of the REST list at `path`.
async function pages(github, path) {
const rows = [];
for (let page = 1; ; page += 1) {
const read = await github("GET", `${path}${path.includes("?") ? "&" : "?"}per_page=100&page=${page}`);
if (!Array.isArray(read)) {
throw new Error(`${path} is not a list`);
}
rows.push(...read);
if (read.length < 100) {
return rows;
}
}
}

// Whether the decision `comment` covers pull request `number` at `head`
// (owner decisions Q17 c and Q18 d on #9436): its decided head (costMarker,
// never edited, so no one with write access moved its sha) is `head`, with
// no compare read, or every change since is a merge of the base branch
// `base`, so the net change against it is the same at both (sameNetChange).
// No marker, an edited or unreadable newest marker (no older one stands in),
// a comment deleted since that marker was posted (deletedSince, read before
// the head check, so deleting a newer marker cannot promote an older head),
// an unusable base name or a read that fails carries nothing.
async function carries(github, repo, number, comment, head, base) {
try {
const marker = costMarker(await pages(github, `/repos/${repo}/issues/${number}/comments`), comment.created_at);
if (marker === null) {
return false;
}
const node = (await github("POST", "/graphql", { query: COMMENT_EDITED_QUERY, variables: { id: marker.node } }))?.data?.node;
if (!unedited(node)) {
return false;
}
const [owner, name] = repo.split("/");
const deleted = await github("POST", "/graphql", { query: COMMENT_DELETED_QUERY, variables: { owner, name, number } });
if (deletedSince(deleted?.data, marker.at)) {
return false;
}
const decided = marker.sha;
if (decided === head) {
return true;
}
if (typeof base !== "string" || !BRANCH.test(base)) {
return false;
}
const then = await github("GET", `/repos/${repo}/compare/${base}...${decided}`);
const now = await github("GET", `/repos/${repo}/compare/${base}...${head}`);
return sameNetChange(then, now);
} catch {
return false;
}
}

// 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) {
// The comment link in `body`, outside HTML comments, that records the
// owner's cost decision for pull request `number` of `repo` at `head`, 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 linked issue in `repo` (the one closing issue, else with none the
// one refsIssue names, read to be an issue and not a pull request), newer
// than the pull request and naming it (namesPull); by a User, not through an
// App; never edited; its first line `**Decision recorded.** Cost:`. Of
// those, the decision is the newest on the pull request, else the newest on
// the issue, as the review lanes' hold step picks it; it covers the head it
// decided and a later one only when every change since is a base merge
// (carries, against the pull request's base branch). `github(method, path,
// body)` reads; a failed read of the pull request or the Refs target decides
// nothing, and one of a comment or its edit state skips that comment.
export async function costDecision(github, repo, number, body, humans, head) {
const links = [...String(body ?? "").replaceAll(HTML_COMMENT, "").matchAll(DECISION_LINK)].filter(
(link) => `${link[1]}/${link[2]}`.toLowerCase() === String(repo).toLowerCase(),
);
Expand All @@ -451,15 +597,32 @@ export async function costDecision(github, repo, number, body, humans) {
}
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()
const refs = closing?.totalCount === 0 ? refsIssue(body) : null;
let issue =
refs ??
(closing?.totalCount === 1 && closing.nodes?.length === 1 && String(closing.nodes[0]?.repository?.nameWithOwner).toLowerCase() === repo.toLowerCase()
? closing.nodes[0].number
: null;
: null);
let checked = refs === null;
const decisions = [];
for (const link of links) {
const target = Number(link[3]);
if (target !== number && target !== issue) {
continue;
}
if (target !== number && !checked) {
// A Refs number may name a pull request; only an issue is consulted.
try {
const read = await github("GET", `/repos/${repo}/issues/${refs}`);
issue = read?.number === refs && read.pull_request == null ? refs : null;
} catch {
return null;
}
checked = true;
if (issue === null) {
continue;
}
}
try {
const comment = await github("GET", `/repos/${repo}/issues/comments/${link[4]}`);
if (
Expand All @@ -468,19 +631,26 @@ export async function costDecision(github, repo, number, body, humans) {
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))
(target !== number && !(Date.parse(comment.created_at) > opened && namesPull(comment.body, number)))
) {
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];
if (unedited(node)) {
decisions.push({ link: link[0], comment, own: target === number });
}
} catch {
// An unreadable comment clears nothing.
}
}
return null;
const at = (d) => {
const made = Date.parse(d.comment.created_at);
return Number.isNaN(made) ? -Infinity : made;
};
// The newest; of two at one time, the first linked.
const newest = (own) => decisions.filter((d) => d.own === own).reduce((best, d) => (best === undefined || at(d) > at(best) ? d : best), undefined);
const decision = newest(true) ?? newest(false);
return decision && (await carries(github, repo, number, decision.comment, head, pull?.baseRefName)) ? decision.link : null;
}

// A GitHub REST and GraphQL reader on fetch, for costDecision.
Expand Down Expand Up @@ -543,7 +713,7 @@ export function holds(body, items, files, holdPaths, lifters = [], decision = nu
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`,
`${cost}; the owner's unedited comment opening '**Decision recorded.** Cost:' on this pull request, or on its linked issue naming this pull request, linked from the body, lifts it for the head it decided and a later head that only merges the base`,
);
}
if (BODY_HOLD.test(String(body ?? ""))) {
Expand Down Expand Up @@ -685,7 +855,7 @@ export async function main(argv = process.argv.slice(2), { env = process.env, gi
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;
costHold(files, null) && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) ? await costDecision(github, repo, Number(number), body, humans, head) : null;
if (decision) {
console.log(`#${number}: ${decision} records the owner's cost decision, which lifts its cost hold.`);
}
Expand Down
Loading
Loading