// The forge's merge tool, `gitea_merge_pull_request` (novox/hq ADR 0266, issue 381, ADR 0323). // // **Served from the watcher's bundle, beside the merge check's one writer** (index.ts, gate.ts; novox/hq issue 456): // when a verdict is judged against an earlier trunk commit than the trunk's head, the tool asks the head's check and // puts its statuses back to pending through that writer, so where the check runs is said and no second check is asked. // Each bundle the runtime serves is its own process; from the tools' bundle the pending bypassed the writer's chain. import { emit } from "@novox/mesh-sdk/events"; import type { ToolDefinition } from "@novox/mesh-sdk/tools"; import type { CommitStatus, GiteaClient } from "./client.js"; import { mergeableUnder, newestByContext } from "./checks.js"; import { MERGE_METHODS, Refused, checkName } from "./guard.js"; import { CHECK_CONTEXT, REPO_CHECK_CONTEXT } from "./pulls.js"; import { announcementOf, askedWords, outOfDateWords, tookFile, tookWords, twelve, verdictAtTrunk } from "./trunk.js"; /** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */ const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/; /** What the merge tool reaches beside the forge: the merge check's one writer, how an event is said, the module's * state directory, and the clock. */ export interface MergeDeps { gates: { rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise }; emit?: (type: string, body: unknown) => Promise; stateDir?: string; now?: () => Date; } export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition { const deps = { ...given, emit: given.emit ?? emit, stateDir: given.stateDir ?? process.env.MESH_GITEA_STATE_DIR, now: given.now ?? (() => new Date()) }; return { name: "gitea_merge_pull_request", description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head " + "and every status its base branch's protection requires succeeded there (mesh/merge-gate, mesh/repo-check), " + "judged against the trunk's head as it is now, optionally deleting the source branch afterwards. Refused " + "otherwise, with nothing merged: no tool merges past a check, and none merges a head that moved after it was " + "reviewed. A verdict judged against an earlier trunk commit is refused, naming both, and the head's check against " + "the trunk's head is asked once, with how long the last check of the repository took: ask again at the same head " + "once it passes — the tool never merges by itself.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, number: { type: "number", description: "the PR number" }, head: { type: "string", description: "the commit you reviewed, as its sha or at least its first 12 characters: " + "refused unless it is the pull request's head" }, method: { type: "string", description: "merge (default) | fast-forward-only | squash | rebase | rebase-merge. " + "Only merge and fast-forward-only can be read back to prove the head you named is what landed" }, delete_branch: { type: "boolean", description: "delete the source branch after merge (default true)" }, }, run: async (args) => { const owner = checkName("owner", args.owner); const repo = checkName("name", args.repo); const number = Number(args.number); const method = args.method ? String(args.method) : "merge"; if (!MERGE_METHODS.has(method)) throw new Refused(`${method} is not a merge method: ${[...MERGE_METHODS].join(", ")}`); const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch); // **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here rather than left // to the forge, whose administrator — the account this tool's token is minted for — may merge past a // rule that lets it. A rule that cannot be read is no rule passed. // **Only the head that was reviewed** (novox/hq issue 381): mesh-controller#197 was reviewed at 065cfa0d, // force-pushed to 3ced96f, checked green there, and merged at 3ced96f, because this tool merged whatever the // head was and nothing told it which head the review had approved. The caller names it; a head that moved // since is refused, and the forge is handed the same head, so a push between this read and the merge is // refused by the forge too. const named = args.head === undefined ? "" : String(args.head).trim().toLowerCase(); if (!named) { throw new Refused(`${owner}/${repo}#${number} is not merged: name the head you reviewed (head), so that only ` + `that commit can land (novox/hq issue 381). Nothing was merged`); } // Twelve characters, not seven: an author who force-pushes can make a commit whose first seven match the // reviewed one in seconds, and the review would then merge it. Twelve cannot be matched that way. if (!REVIEWED_HEAD.test(named)) { throw new Refused(`${owner}/${repo}#${number} is not merged: head ${named} does not name one commit — give its ` + `sha, or at least its first 12 characters. Nothing was merged`); } const pull = await gitea.getPullRequest(owner, repo, number); if (pull.merged) throw new Refused(`${owner}/${repo}#${number} is already merged`); if (!pull.head_sha || !pull.base) throw new Refused(`${owner}/${repo}#${number} names no head commit or base branch`); if (!pull.head_sha.toLowerCase().startsWith(named)) { throw new Refused(`${owner}/${repo}#${number} is not merged: its head is ${pull.head_sha.slice(0, 12)}, not the ` + `${named} you named. If you reviewed an earlier head, review the one it has now, then name it. Nothing was merged`); } const rule = await gitea.branchProtection(owner, repo, pull.base); // Only into a branch whose protection requires the checks: a pull request whose base was moved to a // branch nothing guards would merge past every check (novox/hq ADR 0266). if (!rule || !rule.enable_status_check || !(rule.status_check_contexts ?? []).filter(Boolean).length) { throw new Refused(`${owner}/${repo}#${number} merges into ${pull.base}, which no protection rule requiring ` + `the checks guards: a tool merges only into such a branch (novox/hq ADR 0266). Nothing was merged`); } // **The verdict must be judged against the trunk's head** (novox/hq ADR 0323 §5, issue 341): a check judges the // head merged into a named trunk commit, and a merge of another trunk is not the one that check judged. Compared // here at the moment the merge is asked, not left to the statuses the trunk's move resets a pass later. When they // differ nothing is merged, and the head's check against the trunk's head is asked once — the announcement a new // head makes — never again while one is on its way. The caller asks again at the reviewed head once it passes. const trunkHead = await gitea.branchHead(owner, repo, pull.base); const checks = await gitea.headChecks(owner, repo, pull.head_sha); const at = verdictAtTrunk(checks, trunkHead); if (!at.current) { const judged = at.judged ? `was judged against trunk ${twelve(at.judged)}` : "names no trunk commit it was judged against"; const took = tookWords(tookFile(deps.stateDir), `${owner}/${repo}`); const head = twelve(pull.head_sha), now = twelve(trunkHead); if (at.onItsWay) { throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` + `${now}. Its check is already asked and not yet answered (${checks[CHECK_CONTEXT]?.description}), so it is not ` + `asked again; ${took}. Ask again at ${head} once both statuses pass: this tool never merges by itself ` + `(novox/hq ADR 0323). A check that never answers is asked again with mesh-delivery.recheck. Nothing was merged`); } // Pending first, so a second ask finds the check on its way; then the head is announced as a new head is. An // announcement that cannot be made puts the statuses back to out of date, so the next ask asks again rather // than wait for ever on a check nobody asked. const contexts = [CHECK_CONTEXT, ...(checks[REPO_CHECK_CONTEXT] ? [REPO_CHECK_CONTEXT] : [])]; // Through the module's one writer of the merge check (gate.ts, novox/hq issue 456): the pending opens a new // check of the head, so where it runs is said ("building on …") and its verdict is not weighed as older. const say = async (description: string) => { for (const context of contexts) { await deps.gates.rechecked(owner, repo, String(pull.head_sha), { state: "pending", context, description, ...(pull.html_url ? { target_url: pull.html_url } : {}) }); } }; await say(askedWords(deps.now(), trunkHead)); try { const cloneURL = (await gitea.getRepo(owner, repo)).clone_url; await deps.emit("pull.updated", await announcementOf(gitea, owner, repo, cloneURL, pull)); } catch (err) { await say(outOfDateWords(at.judged, trunkHead)).catch(() => {}); throw new Error(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` + `${now}, and its check could not be asked: ${err instanceof Error ? err.message : err}. Nothing was merged`); } throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` + `${now}. The check of head ${head} merged into trunk ${now} was asked; ${took}. Ask again at ${head} once both ` + `statuses pass: this tool never merges by itself (novox/hq ADR 0323). In a busy repository another merge may ` + `move the trunk again first; this is then refused and asked once more. A check that never answers is asked ` + `again with mesh-delivery.recheck. Nothing was merged`); } const statuses = newestByContext((await gitea.combinedStatus(owner, repo, pull.head_sha)).statuses); const verdict = mergeableUnder(pull.base, rule, statuses); if (verdict.mergeable !== true) { throw new Refused(`${owner}/${repo}#${number} is not merged: ${verdict.says}. A pull request merges once ` + `its checks succeed; no tool merges past them (novox/hq ADR 0266)`); } await gitea.mergePullRequest(owner, repo, number, method, deleteBranch, pull.head_sha); // The merge commit only exists now; answered so the caller can follow what is built from it. // pull.merged is the events entrypoint's to announce (index.ts), once, within its poll. // From here the merge has happened. Nothing below may read as if it had not: a read-back that fails or cannot // be done is said beside merged: true, and only a proven other head is an error — one that says the merge happened. const answer: Record = { merged: true, number, method, head: pull.head_sha, deleted_branch: deleteBranch }; let landed: string | undefined; try { const merged = await gitea.getPullRequest(owner, repo, number); answer.merge_commit_sha = merged.merge_commit_sha; // What landed, read back from the commit the merge made rather than from the forge's record of the pull // request, which is the very thing a race would leave behind. A merge commit's second parent is the head it // joined; a fast-forward lands the head itself. A squash or a rebase makes new commits that name no head. if (!merged.merge_commit_sha) { answer.read_back = "the forge named no merge commit, so what landed cannot be read back"; } else if (method === "merge") { const parents = await gitea.commitParents(owner, repo, merged.merge_commit_sha); if (parents.length === 2) landed = parents[1]; else answer.read_back = `the merge commit has ${parents.length} parent(s), not two: what landed cannot be read back`; } else if (method === "fast-forward-only") { // That the forge names the head itself as a fast-forward's merge commit is assumed, not seen on this forge: // a different sha is said, not raised as another head having landed. if (merged.merge_commit_sha === pull.head_sha) landed = merged.merge_commit_sha; else answer.read_back = `the forge named ${merged.merge_commit_sha.slice(0, 12)} as this fast-forward's commit, ` + `not the head: that the head landed could not be confirmed`; } else { answer.read_back = `a ${method} makes new commits that name no head: what landed cannot be read back`; } } catch (err) { answer.read_back = `the merge happened, but reading back what landed failed: ${(err as Error).message}`; } // Loud, not a field beside merged: true that a caller reading only that would miss. The merge cannot be taken // back by this tool; what must not happen is that it reads as the merge that was asked for. if (landed !== undefined && landed !== pull.head_sha) { throw new Error(`${owner}/${repo}#${number} WAS merged (${answer.merge_commit_sha}), but the forge merged ` + `${landed.slice(0, 12)}, not the ${pull.head_sha.slice(0, 12)} it was handed and you reviewed: a commit nobody ` + `reviewed has landed. Read it before anything is built from it (novox/hq issue 381)`); } if (landed !== undefined) answer.landed = landed; return answer; }, }; }